Skip to content

Commit 422db86

Browse files
committed
Refresh CMSIS and Problems view after saving merged file
1 parent 40a4cf5 commit 422db86

5 files changed

Lines changed: 263 additions & 56 deletions

File tree

src/desktop/extension.ts

Lines changed: 3 additions & 1 deletion
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 { MergeSessionCoordinatorImpl } from '../views/solution-outline/commands/merge-session-coordinator';
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';
@@ -220,7 +221,8 @@ export const activate = async (context: ExtensionContext): Promise<CsolutionExte
220221
const fileDecorationProviderManager = new FileDecorationProviderManagerImpl();
221222
const treeViewProviderImpl = new TreeViewProviderImpl(SolutionOutlineView.treeViewId);
222223
const treeViewFileDecorationProvider = new TreeViewFileDecorationProvider(fileDecorationProviderManager, themeProvider);
223-
const mergeCommand = new MergeCommand(commandsProvider);
224+
const mergeSessionCoordinator = new MergeSessionCoordinatorImpl(solutionManager);
225+
const mergeCommand = new MergeCommand(commandsProvider, mergeSessionCoordinator);
224226
const buildCommand = new BuildCommand(buildTaskProvider, commandsProvider, buildTaskDefinitionBuilder);
225227
const runGeneratorCommand = new GeneratorCommand(commandsProvider, solutionManager, outputChannelProvider, cmsisToolboxManager);
226228
const armclangDefineGetter = new ArmclangDefineGetter(processManager, workspaceFsProvider);

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

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,20 @@ import * as vscode from 'vscode';
3636
import { extensionContextFactory } from '../../../vscode-api/extension-context.factories';
3737
import { commandsProviderFactory, MockCommandsProvider } from '../../../vscode-api/commands-provider.factories';
3838
import { MergeCommand } from './merge-command';
39-
import * as manifest from '../../../manifest';
4039
import { COutlineItem } from '../tree-structure/solution-outline-item';
4140
import * as child_process from 'child_process';
4241
import * as os from 'os';
4342
import * as path from 'path';
4443
import * as fsUtils from '../../../utils/fs-utils';
44+
import { MergeSessionCoordinator } from './merge-session-coordinator';
4545

4646
jest.mock('child_process');
4747
jest.mock('os');
4848

4949
describe('MergeCommand', () => {
5050
let commandsProvider: MockCommandsProvider;
5151
let command: MergeCommand;
52+
let mergeSessionCoordinator: jest.Mocked<MergeSessionCoordinator>;
5253
const testDataHandler = new TestDataHandler();
5354
let tmpDir: string;
5455

@@ -79,7 +80,12 @@ describe('MergeCommand', () => {
7980
fsUtils.writeTextFile(path.join(tmpDir, 'component.c.base@1.0.0'), '// base\n');
8081

8182
commandsProvider = commandsProviderFactory();
82-
command = new MergeCommand(commandsProvider);
83+
mergeSessionCoordinator = {
84+
activate: jest.fn().mockResolvedValue(),
85+
startSession: jest.fn(),
86+
onMergeProcessExit: jest.fn().mockResolvedValue(),
87+
};
88+
command = new MergeCommand(commandsProvider, mergeSessionCoordinator);
8389

8490
componentNode = new COutlineItem('component');
8591
componentNode.setTag('component');
@@ -98,6 +104,7 @@ describe('MergeCommand', () => {
98104
it('registers the command on activation', async () => {
99105
await command.activate(extensionContextFactory());
100106

107+
expect(mergeSessionCoordinator.activate).toHaveBeenCalledTimes(1);
101108
expect(commandsProvider.registerCommand).toHaveBeenCalledTimes(1);
102109
expect(commandsProvider.registerCommand).toHaveBeenCalledWith(MergeCommand.mergeFile, expect.any(Function), expect.anything());
103110
});
@@ -337,13 +344,11 @@ describe('MergeCommand', () => {
337344
});
338345

339346
describe('merge execution flow', () => {
340-
it('warns and skips post-merge file operations on non-zero merge exit code', async () => {
347+
it('warns and delegates process exit handling on non-zero merge exit code', async () => {
341348
const commandPrivate = command as unknown as {
342349
getVSCodeExecutablePath: () => string | undefined;
343350
doOpen3WayMerge: (cmd: string) => Promise<number>;
344351
};
345-
const deleteFileIfExistsSpy = jest.spyOn(fsUtils, 'deleteFileIfExists');
346-
const renameFileSpy = jest.spyOn(fsUtils, 'renameFile');
347352
jest.spyOn(fsUtils, 'copyFile').mockImplementation(() => { });
348353
jest.spyOn(fsUtils, 'getFileModificationTime').mockReturnValue(1000);
349354
jest.spyOn(commandPrivate, 'getVSCodeExecutablePath').mockReturnValue('/usr/bin/code');
@@ -354,9 +359,8 @@ describe('MergeCommand', () => {
354359
await command['runVSCodeMerge'](fileNode);
355360

356361
expect(warningSpy).toHaveBeenCalledWith('Merge exited with code 1. Conflicts may exist.');
357-
expect(deleteFileIfExistsSpy).not.toHaveBeenCalled();
358-
expect(renameFileSpy).not.toHaveBeenCalled();
359-
expect(commandsProvider.executeCommand).not.toHaveBeenCalledWith(manifest.REFRESH_COMMAND_ID);
362+
expect(mergeSessionCoordinator.startSession).toHaveBeenCalledTimes(1);
363+
expect(mergeSessionCoordinator.onMergeProcessExit).toHaveBeenCalledWith(1);
360364
});
361365

362366
it('handles merge errors gracefully', async () => {
@@ -400,12 +404,11 @@ describe('MergeCommand', () => {
400404
expect(showErrorMessageSpy).toHaveBeenCalledWith('Merge operation failed: Invalid update file: contains unsupported shell-sensitive characters.');
401405
});
402406

403-
it('performs post-merge file operations and triggers reload when merged file changes', async () => {
407+
it('starts merge session and notifies coordinator when merge exits successfully', async () => {
404408
const local = path.join(tmpDir, 'component.c');
405409
const update = path.join(tmpDir, 'component.c.update@1.0.0');
406410
const base = path.join(tmpDir, 'component.c.base@1.0.0');
407411
const merged = `${local}.merged`;
408-
const expectedBase = path.join(path.dirname(update), path.basename(update).replaceAll('update', 'base'));
409412
const node = new COutlineItem('file');
410413
node.setTag('file');
411414
node.setAttribute('label', 'Component X');
@@ -417,24 +420,21 @@ describe('MergeCommand', () => {
417420
doOpen3WayMerge: (cmd: string) => Promise<number>;
418421
};
419422
const copyFileSpy = jest.spyOn(fsUtils, 'copyFile').mockImplementation(() => { });
420-
const getFileModificationTimeSpy = jest.spyOn(fsUtils, 'getFileModificationTime')
421-
.mockReturnValueOnce(1000)
422-
.mockReturnValueOnce(2000);
423-
const deleteFileIfExistsSpy = jest.spyOn(fsUtils, 'deleteFileIfExists').mockImplementation(() => { });
424-
const renameFileSpy = jest.spyOn(fsUtils, 'renameFile').mockImplementation(() => { });
423+
jest.spyOn(fsUtils, 'getFileModificationTime').mockReturnValue(1000);
425424
jest.spyOn(commandPrivate, 'getVSCodeExecutablePath').mockReturnValue('/usr/bin/code');
426425
jest.spyOn(commandPrivate, 'doOpen3WayMerge').mockResolvedValue(0);
427426

428427
await command['runVSCodeMerge'](node);
429428

430429
expect(copyFileSpy).toHaveBeenCalledWith(local, merged);
431-
expect(copyFileSpy).toHaveBeenCalledWith(local, `${local}.bak`);
432-
expect(getFileModificationTimeSpy).toHaveBeenCalledTimes(2);
433-
expect(deleteFileIfExistsSpy).toHaveBeenCalledWith(local);
434-
expect(deleteFileIfExistsSpy).toHaveBeenCalledWith(base);
435-
expect(renameFileSpy).toHaveBeenCalledWith(update, expectedBase);
436-
expect(renameFileSpy).toHaveBeenCalledWith(merged, local);
437-
expect(commandsProvider.executeCommand).toHaveBeenCalledWith(manifest.REFRESH_COMMAND_ID);
430+
expect(mergeSessionCoordinator.startSession).toHaveBeenCalledWith({
431+
local,
432+
update,
433+
base,
434+
merged,
435+
mergedMTimeBefore: 1000,
436+
});
437+
expect(mergeSessionCoordinator.onMergeProcessExit).toHaveBeenCalledWith(0);
438438
});
439439
});
440440
});

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

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,27 @@
1616

1717
import * as vscode from 'vscode';
1818
import { CommandsProvider } from '../../../vscode-api/commands-provider';
19-
import { PACKAGE_NAME, REFRESH_COMMAND_ID } from '../../../manifest';
19+
import { PACKAGE_NAME } from '../../../manifest';
2020
import { exec, ExecException, execSync } from 'child_process';
2121
import { COutlineItem } from '../tree-structure/solution-outline-item';
2222
import path from 'path';
2323
import * as os from 'os';
2424
import semver from 'semver';
2525
import { extractVersion } from '../../../utils/string-utils';
2626
import * as fsUtils from '../../../utils/fs-utils';
27+
import { MergeSessionCoordinator } from './merge-session-coordinator';
2728

2829
export class MergeCommand {
2930
public static readonly mergeFile = `${PACKAGE_NAME}.mergeFile`;
3031
private static readonly disallowedCmdChars = /[\r\n&|<>^%"']/;
3132

3233
constructor(
3334
private readonly commandsProvider: CommandsProvider,
35+
private readonly mergeSessionCoordinator: MergeSessionCoordinator,
3436
) { }
3537

3638
public async activate(context: Pick<vscode.ExtensionContext, 'subscriptions'>) {
39+
await this.mergeSessionCoordinator.activate(context);
3740
context.subscriptions.push(
3841
this.commandsProvider.registerCommand(MergeCommand.mergeFile, this.handleMergeCommand, this),
3942
);
@@ -107,19 +110,19 @@ export class MergeCommand {
107110
const mergedMTimeBefore = fsUtils.getFileModificationTime(merged);
108111

109112
try {
113+
this.mergeSessionCoordinator.startSession({
114+
local,
115+
update,
116+
base,
117+
merged,
118+
mergedMTimeBefore,
119+
});
110120
const command = this.buildMergeCommand(codePath, local, update, base, merged);
111121
const exitCode = await this.doOpen3WayMerge(command);
112-
113-
// get the modification time after merge
114-
const mergedMTimeAfter = fsUtils.getFileModificationTime(merged);
122+
await this.mergeSessionCoordinator.onMergeProcessExit(exitCode);
115123

116124
if (exitCode !== 0) {
117125
console.warn(`Merge exited with code ${exitCode}. Conflicts may exist.`);
118-
return;
119-
}
120-
121-
if (exitCode === 0 && mergedMTimeAfter > mergedMTimeBefore) {
122-
await this.performPostMergeOperations(local, update, base, merged);
123126
}
124127

125128
} catch (err) {
@@ -147,30 +150,6 @@ export class MergeCommand {
147150
return { local, update, base };
148151
}
149152

150-
private async performPostMergeOperations(local: string, update: string, base: string, merged: string): Promise<void> {
151-
// create .bak file of local file
152-
const backupPath = `${local}.bak`;
153-
fsUtils.copyFile(local, backupPath);
154-
155-
// delete local file
156-
fsUtils.deleteFileIfExists(local);
157-
158-
// delete base file
159-
fsUtils.deleteFileIfExists(base);
160-
161-
// rename update file to base file
162-
const newBaseFileName = path.basename(update).replaceAll('update', 'base');
163-
const baseDirPath = path.dirname(update);
164-
const newBase = path.join(baseDirPath, newBaseFileName);
165-
fsUtils.renameFile(update, newBase);
166-
167-
// rename merged file to local file
168-
fsUtils.renameFile(merged, local);
169-
170-
// refresh tree view to update file status
171-
await this.commandsProvider.executeCommand(REFRESH_COMMAND_ID);
172-
}
173-
174153
private getVSCodeExecutablePath(): string | undefined {
175154
const platform = os.platform();
176155

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
jest.mock('vscode');
18+
19+
import * as path from 'path';
20+
import * as vscode from 'vscode';
21+
import { TestDataHandler } from '../../../__test__/test-data';
22+
import { MergeSessionCoordinatorImpl } from './merge-session-coordinator';
23+
import * as fsUtils from '../../../utils/fs-utils';
24+
25+
describe('MergeSessionCoordinator', () => {
26+
const testDataHandler = new TestDataHandler();
27+
let tmpDir: string;
28+
let solutionManager: { refresh: jest.Mock<Promise<void>, []> };
29+
let coordinator: MergeSessionCoordinatorImpl;
30+
let saveEmitter: vscode.EventEmitter<vscode.TextDocument>;
31+
32+
beforeEach(async () => {
33+
jest.restoreAllMocks();
34+
jest.clearAllMocks();
35+
36+
testDataHandler.rmTmpDir();
37+
tmpDir = testDataHandler.tmpDir;
38+
39+
saveEmitter = new vscode.EventEmitter<vscode.TextDocument>();
40+
(vscode.workspace as unknown as { onDidSaveTextDocument: vscode.Event<vscode.TextDocument> }).onDidSaveTextDocument = saveEmitter.event;
41+
42+
solutionManager = {
43+
refresh: jest.fn().mockResolvedValue(),
44+
};
45+
46+
coordinator = new MergeSessionCoordinatorImpl(solutionManager);
47+
48+
await coordinator.activate({ subscriptions: [] } as unknown as vscode.ExtensionContext);
49+
});
50+
51+
afterAll(() => {
52+
testDataHandler.dispose();
53+
});
54+
55+
it('finalizes merge on save and refreshes solution', async () => {
56+
const local = path.join(tmpDir, 'component.c');
57+
const update = path.join(tmpDir, 'component.c.update@1.0.0');
58+
const base = path.join(tmpDir, 'component.c.base@1.0.0');
59+
const merged = `${local}.merged`;
60+
61+
fsUtils.writeTextFile(local, '// local\n');
62+
fsUtils.writeTextFile(update, '// update\n');
63+
fsUtils.writeTextFile(base, '// base\n');
64+
fsUtils.writeTextFile(merged, '// merged\n');
65+
66+
coordinator.startSession({ local, update, base, merged, mergedMTimeBefore: 0 });
67+
68+
await (coordinator as unknown as {
69+
handleDidSaveTextDocument: (document: vscode.TextDocument) => Promise<void>
70+
}).handleDidSaveTextDocument({ uri: { fsPath: merged } } as vscode.TextDocument);
71+
72+
const newBase = path.join(tmpDir, 'component.c.base@1.0.0');
73+
expect(fsUtils.fileExists(`${local}.bak`)).toBeTruthy();
74+
expect(fsUtils.fileExists(local)).toBeTruthy();
75+
expect(fsUtils.readTextFile(local)).toContain('// merged');
76+
expect(fsUtils.fileExists(merged)).toBeFalsy();
77+
expect(fsUtils.fileExists(newBase)).toBeTruthy();
78+
79+
expect(solutionManager.refresh).toHaveBeenCalledTimes(1);
80+
});
81+
82+
it('ignores save events for unrelated files', async () => {
83+
const local = path.join(tmpDir, 'component.c');
84+
const update = path.join(tmpDir, 'component.c.update@1.0.0');
85+
const base = path.join(tmpDir, 'component.c.base@1.0.0');
86+
const merged = `${local}.merged`;
87+
88+
fsUtils.writeTextFile(local, '// local\n');
89+
fsUtils.writeTextFile(update, '// update\n');
90+
fsUtils.writeTextFile(base, '// base\n');
91+
fsUtils.writeTextFile(merged, '// merged\n');
92+
93+
coordinator.startSession({ local, update, base, merged, mergedMTimeBefore: 0 });
94+
95+
await (coordinator as unknown as {
96+
handleDidSaveTextDocument: (document: vscode.TextDocument) => Promise<void>
97+
}).handleDidSaveTextDocument({ uri: { fsPath: path.join(tmpDir, 'other.c') } } as vscode.TextDocument);
98+
99+
expect(solutionManager.refresh).not.toHaveBeenCalled();
100+
expect(fsUtils.fileExists(merged)).toBeTruthy();
101+
});
102+
103+
it('finalizes on merge process exit when save hook did not run', async () => {
104+
const local = path.join(tmpDir, 'component.c');
105+
const update = path.join(tmpDir, 'component.c.update@1.0.0');
106+
const base = path.join(tmpDir, 'component.c.base@1.0.0');
107+
const merged = `${local}.merged`;
108+
109+
fsUtils.writeTextFile(local, '// local\n');
110+
fsUtils.writeTextFile(update, '// update\n');
111+
fsUtils.writeTextFile(base, '// base\n');
112+
fsUtils.writeTextFile(merged, '// merged\n');
113+
114+
coordinator.startSession({ local, update, base, merged, mergedMTimeBefore: 0 });
115+
await coordinator.onMergeProcessExit(0);
116+
117+
expect(fsUtils.fileExists(local)).toBeTruthy();
118+
expect(fsUtils.readTextFile(local)).toContain('// merged');
119+
expect(solutionManager.refresh).toHaveBeenCalledTimes(1);
120+
});
121+
});

0 commit comments

Comments
 (0)