Skip to content

Commit e7f44b4

Browse files
committed
Add "Open Merge View" link in Problems view
1 parent 3ab97a1 commit e7f44b4

10 files changed

Lines changed: 349 additions & 14 deletions

src/desktop/extension.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { EditCommand } from '../views/solution-outline/commands/edit-command';
5959
import { OpenCommand } from '../views/solution-outline/commands/open-command';
6060
import { FindCommand } from '../views/solution-outline/commands/find-command';
6161
import { MergeCommand } from '../views/solution-outline/commands/merge-command';
62+
import { MergeNodeResolverImpl } from '../views/solution-outline/merge-node-resolver';
6263
import { SolutionOutlineView } from '../views/solution-outline/solution-outline';
6364
import { TreeViewFileDecorationProvider } from '../views/solution-outline/treeview-decoration-provider';
6465
import { TreeViewProviderImpl } from '../views/solution-outline/treeview-provider';
@@ -217,8 +218,11 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
217218
const copyHeaderCommand = new CopyHeaderCommand(commandsProvider);
218219
const openCommand = new OpenCommand(solutionManager, commandsProvider, externalFileOpener);
219220
const findCommand = new FindCommand(commandsProvider);
220-
const mergeCommand = new MergeCommand(commandsProvider, activeSolutionTracker);
221221
const fileDecorationProviderManager = new FileDecorationProviderManagerImpl();
222+
const treeViewProviderImpl = new TreeViewProviderImpl(SolutionOutlineView.treeViewId);
223+
const treeViewFileDecorationProvider = new TreeViewFileDecorationProvider(fileDecorationProviderManager, themeProvider);
224+
const mergeNodeResolver = new MergeNodeResolverImpl();
225+
const mergeCommand = new MergeCommand(commandsProvider, activeSolutionTracker, mergeNodeResolver);
222226
const buildCommand = new BuildCommand(buildTaskProvider, commandsProvider, buildTaskDefinitionBuilder);
223227
const runGeneratorCommand = new GeneratorCommand(commandsProvider, solutionManager, outputChannelProvider, cmsisToolboxManager);
224228
const armclangDefineGetter = new ArmclangDefineGetter(processManager, workspaceFsProvider);
@@ -245,10 +249,7 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
245249
const targetPackCommand = new TargetPackCommandImpl(commandsProvider, solutionManager);
246250
const debugHardwareCommands = new DebugHardwareCommands(commandsProvider, solutionManager);
247251
const csolutionExtension = new CsolutionExtensionImpl(solutionCreator, buildTaskProvider, dataManager);
248-
const treeViewProviderImpl = new TreeViewProviderImpl(SolutionOutlineView.treeViewId);
249-
250-
const treeViewFileDecorationProvider = new TreeViewFileDecorationProvider(fileDecorationProviderManager, themeProvider);
251-
const solutionOutline = new SolutionOutlineView(solutionManager, treeViewProviderImpl, globalStateProvider, treeViewFileDecorationProvider);
252+
const solutionOutline = new SolutionOutlineView(solutionManager, treeViewProviderImpl, globalStateProvider, treeViewFileDecorationProvider, mergeNodeResolver);
252253
const cmsisCommands = new CmsisCommands(configurationProvider, commandsProvider, solutionManager, debugProvider, serialMonitorExtension);
253254
const buildStopCommand = new BuildStopCommand(commandsProvider, buildTaskProvider);
254255
const configurationWizardView = new ConfWizWebview(context);

src/solutions/solution-problems.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { solutionManagerFactory, MockSolutionManager } from './solution-manager.
2323
import { SolutionEventHub } from './solution-event-hub';
2424
import { enrichLogMessagesFromToolOutput, SolutionProblemsImpl } from './solution-problems';
2525
import { waitTimeout } from '../__test__/test-waits';
26+
import { createMergeCommandUri, MERGE_VIEW_LINK_LABEL } from '../views/solution-outline/commands/merge-message-parser';
2627

2728
const solutionPath = '/work/app.csolution.yml';
2829
const layerPath = '/work/config/mylayer.clayer.yml';
@@ -173,4 +174,36 @@ describe('SolutionProblems', () => {
173174
expect(messages.errors[0]).toContain('.vscode');
174175
expect(messages.errors[0]).toContain('settings.json:3:5 - exec: "west": executable file not found in $PATH; review "cmsis-csolution.environmentVariables"');
175176
});
177+
178+
it('creates Open Merge View code action for merge advisory diagnostics', async () => {
179+
await solutionProblems.activate({ subscriptions: [] } as unknown as ExtensionContext);
180+
const setSpy = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set');
181+
const localPath = 'C:/Users/myuser/my_csolution_examples/CubeMX/CubeMX/RTE/CMSIS/RTX_Config.c';
182+
183+
await eventHub.fireConvertCompleted({
184+
severity: 'warning',
185+
detection: false,
186+
logMessages: {
187+
success: true,
188+
errors: [],
189+
warnings: [
190+
`file '${localPath}' update recommended; merge content from update file, rename update file to base file and remove previous base file`,
191+
],
192+
info: [],
193+
},
194+
});
195+
await waitTimeout();
196+
197+
const setCalls = setSpy.mock.calls as unknown as Array<[vscode.Uri, readonly vscode.Diagnostic[] | undefined]>;
198+
const diagnostics = setCalls.flatMap(([, entries]) => [...(entries ?? [])]);
199+
const mergeDiagnostic = diagnostics.find(d => d.code !== undefined);
200+
201+
expect(mergeDiagnostic).toBeDefined();
202+
expect(mergeDiagnostic!.message).toBe('RTX_Config.c has a new version available for merge.');
203+
expect(mergeDiagnostic!.source).toBe('csolution');
204+
expect(mergeDiagnostic!.code).toEqual({
205+
value: MERGE_VIEW_LINK_LABEL,
206+
target: vscode.Uri.parse(createMergeCommandUri(localPath)),
207+
});
208+
});
176209
});

src/solutions/solution-problems.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as fsUtils from '../utils/fs-utils';
2222
import { getFileNameFromPath } from '../utils/path-utils';
2323
import { stripTwoExtensions } from '../utils/string-utils';
2424
import { getWorkspaceFolder } from '../utils/vscode-utils';
25+
import { createMergeCommandUri, createMergeDiagnosticMessage, MERGE_VIEW_LINK_LABEL, parseMergeMessage } from '../views/solution-outline/commands/merge-message-parser';
2526
import { SolutionManager } from './solution-manager';
2627
import { ConvertResultData, SolutionEventHub } from './solution-event-hub';
2728

@@ -144,7 +145,7 @@ export class SolutionProblemsImpl implements SolutionProblems {
144145
// parse message according to logMessageRegex
145146
const m = message.match(this.logMessageRegex);
146147
if (m?.groups) {
147-
const { filename, line, column, message } = m.groups;
148+
const { filename, line, column, message: diagnosticMessage } = m.groups;
148149
const normalizedFilename = filename ? getFileNameFromPath(filename) : undefined;
149150
const fromMap = (filename && files.get(filename)) || (normalizedFilename && files.get(normalizedFilename));
150151
const file = fromMap || (filename && path.isAbsolute(filename) ? filename : undefined) || this.solutionManager.getCsolution()?.solutionPath;
@@ -164,18 +165,31 @@ export class SolutionProblemsImpl implements SolutionProblems {
164165
// Keep default endCharacter when document cannot be opened.
165166
}
166167
}
168+
const merge = parseMergeMessage(diagnosticMessage);
169+
const normalizedMessage = merge
170+
? createMergeDiagnosticMessage(merge.localPath)
171+
: diagnosticMessage;
172+
167173
const range = new vscode.Range(startLine, startCharacter, startLine, endCharacter);
168-
const entry = new vscode.Diagnostic(range, message, severity);
174+
const entry = new vscode.Diagnostic(range, normalizedMessage, severity);
169175
entry.source = 'csolution';
170176

171177
if (!line && !column) {
172-
// add 'Find in Files' action only if no line/column info is available
173-
const args = this.createQueryArgs(message);
174-
if (args) {
178+
// add merge action first, fallback to 'Find in Files' if not a merge advisory
179+
if (merge) {
180+
const commandUri = createMergeCommandUri(merge.localPath);
175181
entry.code = {
176-
value: 'Find in Files',
177-
target: vscode.Uri.parse(`command:workbench.action.findInFiles?${args}`)
182+
value: MERGE_VIEW_LINK_LABEL,
183+
target: vscode.Uri.parse(commandUri),
178184
};
185+
} else {
186+
const args = this.createQueryArgs(diagnosticMessage);
187+
if (args) {
188+
entry.code = {
189+
value: 'Find in Files',
190+
target: vscode.Uri.parse(`command:workbench.action.findInFiles?${args}`)
191+
};
192+
}
179193
}
180194
}
181195

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import * as fs from 'fs';
4545
import * as child_process from 'child_process';
4646
import * as os from 'os';
4747
import * as path from 'path';
48+
import { MergeNodeResolver } from '../merge-node-resolver';
4849

4950
jest.mock('fs');
5051
jest.mock('child_process');
@@ -93,11 +94,51 @@ describe('MergeCommand', () => {
9394
it('registers the command on activation', async () => {
9495
await command.activate(extensionContextFactory());
9596

96-
expect(commandsProvider.registerCommand).toHaveBeenCalledTimes(1);
97+
expect(commandsProvider.registerCommand).toHaveBeenCalledTimes(2);
9798
expect(commandsProvider.registerCommand).toHaveBeenCalledWith(MergeCommand.mergeFile, expect.any(Function), expect.anything());
99+
expect(commandsProvider.registerCommand).toHaveBeenCalledWith(MergeCommand.mergeFileFromPath, expect.any(Function), expect.anything());
98100
});
99101

100102
describe('cross-platform', () => {
103+
it('shows error when merge path is missing', async () => {
104+
const showErrorMessageSpy = jest.spyOn(vscode.window, 'showErrorMessage');
105+
106+
await command['runVSCodeMergeFromPath']('');
107+
108+
expect(showErrorMessageSpy).toHaveBeenCalledWith('Cannot open merge view: merge file path is missing.');
109+
});
110+
111+
it('shows error when merge node resolver has no match', async () => {
112+
const showErrorMessageSpy = jest.spyOn(vscode.window, 'showErrorMessage');
113+
const resolver: MergeNodeResolver = {
114+
setTreeRoot: jest.fn(),
115+
findMergeNodeByLocalPath: jest.fn().mockReturnValue(undefined),
116+
};
117+
const commandWithResolver = new MergeCommand(commandsProvider, activeSolutionTracker, resolver);
118+
119+
await commandWithResolver['runVSCodeMergeFromPath']('C:/workspace/RTE/CMSIS/RTX_Config.c');
120+
121+
expect(showErrorMessageSpy).toHaveBeenCalledWith('Cannot open merge view: merge metadata not available. Reload solution and try again.');
122+
});
123+
124+
it('resolves node by path and delegates to merge flow', async () => {
125+
const resolverNode = new COutlineItem('file');
126+
const resolver: MergeNodeResolver = {
127+
setTreeRoot: jest.fn(),
128+
findMergeNodeByLocalPath: jest.fn().mockReturnValue(resolverNode),
129+
};
130+
const commandWithResolver = new MergeCommand(commandsProvider, activeSolutionTracker, resolver);
131+
const commandWithResolverPrivate = commandWithResolver as unknown as {
132+
runVSCodeMerge: (node: COutlineItem) => Promise<void>;
133+
};
134+
const runVSCodeMergeSpy = jest.spyOn(commandWithResolverPrivate, 'runVSCodeMerge').mockResolvedValue(undefined);
135+
136+
await commandWithResolver['runVSCodeMergeFromPath']('C:/workspace/RTE/CMSIS/RTX_Config.c');
137+
138+
expect(resolver.findMergeNodeByLocalPath).toHaveBeenCalled();
139+
expect(runVSCodeMergeSpy).toHaveBeenCalledWith(resolverNode);
140+
});
141+
101142
it('shows error if node is not passed', async () => {
102143
const showErrorMessageSpy = jest.spyOn(vscode.window, 'showErrorMessage');
103144
// @ts-expect-error - testing behavior when `runVSCodeMerge` receives null

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,46 @@ import path from 'path';
2323
import * as os from 'os';
2424
import * as fs from 'fs';
2525
import { ActiveSolutionTracker } from '../../../solutions/active-solution-tracker';
26+
import { MergeNodeResolver } from '../merge-node-resolver';
2627

2728
export class MergeCommand {
2829
public static readonly mergeFile = `${PACKAGE_NAME}.mergeFile`;
30+
public static readonly mergeFileFromPath = `${PACKAGE_NAME}.mergeFileFromPath`;
2931
private static readonly disallowedCmdChars = /[\r\n&|<>^%"']/;
3032

3133
constructor(
3234
private readonly commandsProvider: CommandsProvider,
33-
private readonly activeSolutionTracker: ActiveSolutionTracker
35+
private readonly activeSolutionTracker: ActiveSolutionTracker,
36+
private readonly mergeNodeResolver?: MergeNodeResolver,
3437
) { }
3538

3639
public async activate(context: Pick<vscode.ExtensionContext, 'subscriptions'>) {
3740
context.subscriptions.push(
3841
this.commandsProvider.registerCommand(MergeCommand.mergeFile, (node: COutlineItem) => {
3942
this.runVSCodeMerge(node);
4043
}, this),
44+
this.commandsProvider.registerCommand(MergeCommand.mergeFileFromPath, (localPath: string) => {
45+
this.runVSCodeMergeFromPath(localPath);
46+
}, this),
4147
);
4248
}
4349

50+
private async runVSCodeMergeFromPath(localPath: string): Promise<void> {
51+
if (!localPath) {
52+
vscode.window.showErrorMessage('Cannot open merge view: merge file path is missing.');
53+
return;
54+
}
55+
56+
const normalizedPath = path.resolve(localPath);
57+
const node = this.mergeNodeResolver?.findMergeNodeByLocalPath(normalizedPath);
58+
if (!node) {
59+
vscode.window.showErrorMessage('Cannot open merge view: merge metadata not available. Reload solution and try again.');
60+
return;
61+
}
62+
63+
await this.runVSCodeMerge(node);
64+
}
65+
4466
private async runVSCodeMerge(node: COutlineItem): Promise<void> {
4567
if (!node) {
4668
vscode.window.showErrorMessage('File data is not available for merge operation.');
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 { createMergeCommandUri, createMergeDiagnosticMessage, MERGE_VIEW_LINK_LABEL, parseMergeMessage } from './merge-message-parser';
18+
19+
describe('merge-message-parser', () => {
20+
it.each([
21+
'required',
22+
'recommended',
23+
'suggested',
24+
'mandatory',
25+
])('parses merge advisory message for status %s', (status) => {
26+
const line = `file 'C:/workspace/RTE/CMSIS/RTX_Config.c' update ${status}; merge content from update file, rename update file to base file and remove previous base file`;
27+
28+
const parsed = parseMergeMessage(line);
29+
30+
expect(parsed).toEqual(expect.objectContaining({
31+
localPath: 'C:/workspace/RTE/CMSIS/RTX_Config.c',
32+
}));
33+
expect(parsed?.matchLength).toBeGreaterThan(0);
34+
});
35+
36+
it('returns undefined for non-merge message', () => {
37+
expect(parseMergeMessage('warning: unrelated diagnostic')).toBeUndefined();
38+
});
39+
40+
it('creates merge command uri with encoded path argument', () => {
41+
const uri = createMergeCommandUri('C:/workspace/RTE/CMSIS/RTX_Config.c');
42+
43+
expect(uri).toContain('command:cmsis-csolution.mergeFileFromPath?');
44+
expect(decodeURIComponent(uri.split('?')[1])).toBe('["C:/workspace/RTE/CMSIS/RTX_Config.c"]');
45+
});
46+
47+
it('creates concise problems-view message', () => {
48+
const message = createMergeDiagnosticMessage('C:/workspace/RTE/CMSIS/RTX_Config.c');
49+
50+
expect(message).toBe('RTX_Config.c has a new version available for merge.');
51+
expect(message).not.toContain(MERGE_VIEW_LINK_LABEL);
52+
});
53+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
19+
export const MERGE_VIEW_LINK_LABEL = 'Open Merge View';
20+
21+
const mergeMessageRegex = /file\s+'([^']+)'\s+update\s+(?:required|recommended|suggested|mandatory);\s*merge content from update file/i;
22+
23+
export interface MergeMessageMatch {
24+
localPath: string;
25+
matchStart: number;
26+
matchLength: number;
27+
}
28+
29+
export function parseMergeMessage(line: string): MergeMessageMatch | undefined {
30+
const match = mergeMessageRegex.exec(line);
31+
if (!match || match.index === undefined) {
32+
return undefined;
33+
}
34+
35+
return {
36+
localPath: match[1],
37+
matchStart: match.index,
38+
matchLength: match[0].length,
39+
};
40+
}
41+
42+
export function createMergeCommandUri(localPath: string): string {
43+
const commandId = 'cmsis-csolution.mergeFileFromPath';
44+
const args = encodeURIComponent(JSON.stringify([localPath]));
45+
return `command:${commandId}?${args}`;
46+
}
47+
48+
export function createMergeDiagnosticMessage(localPath: string): string {
49+
return `${path.basename(localPath)} has a new version available for merge.`;
50+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 manifest from '../../manifest';
18+
import { COutlineItem } from './tree-structure/solution-outline-item';
19+
import { MergeNodeResolverImpl } from './merge-node-resolver';
20+
21+
describe('MergeNodeResolverImpl', () => {
22+
it('returns undefined when tree root is not set', () => {
23+
const resolver = new MergeNodeResolverImpl();
24+
25+
expect(resolver.findMergeNodeByLocalPath('C:/workspace/RTX_Config.c')).toBeUndefined();
26+
});
27+
28+
it('finds merge-enabled file node by local path', () => {
29+
const root = new COutlineItem('root');
30+
const group = root.createChild('group');
31+
const mergeFile = group.createChild('file');
32+
mergeFile.addFeature(manifest.MERGE_FILE_CONTEXT);
33+
mergeFile.setAttribute('local', 'C:/workspace/RTE/CMSIS/RTX_Config.c');
34+
35+
const resolver = new MergeNodeResolverImpl();
36+
resolver.setTreeRoot(root);
37+
38+
const found = resolver.findMergeNodeByLocalPath('C:/workspace/RTE/CMSIS/RTX_Config.c');
39+
expect(found).toBe(mergeFile);
40+
});
41+
42+
it('ignores file node without merge feature even if local path matches', () => {
43+
const root = new COutlineItem('root');
44+
const file = root.createChild('file');
45+
file.setAttribute('local', 'C:/workspace/RTE/CMSIS/RTX_Config.c');
46+
47+
const resolver = new MergeNodeResolverImpl();
48+
resolver.setTreeRoot(root);
49+
50+
expect(resolver.findMergeNodeByLocalPath('C:/workspace/RTE/CMSIS/RTX_Config.c')).toBeUndefined();
51+
});
52+
});

0 commit comments

Comments
 (0)