diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index c2cea6658..b2ed03ae1 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -85,6 +85,7 @@ import { EnvironmentManager } from './env-manager'; import { ExtensionApiWrapper } from '../vscode-api/extension-api-wrapper'; import { SerialMonitorApi, Version } from '@microsoft/vscode-serial-monitor-api'; import { SolutionEventHub } from '../solutions/solution-event-hub'; +import { SolutionRpcData } from '../solutions/solution-rpc-data'; import { ManageSolutionCustomEditorProvider, registerManageSolutionCommand } from '../views/manage-solution/manage-solution-custom-editor'; let installDefaultToolsetProcess: Promise | undefined; @@ -130,9 +131,18 @@ export const activate = async (context: ExtensionContext): Promise>] { + public getActiveTargetTypeWrap() { const activeTarget = this.getActiveTargetType() ?? ''; - const activeTargetSetIdx = this.cmsisJsonFile.getSelectedSet(activeTarget); - const activeTargetWrap = this.csolutionYml.getTargetType(activeTarget); + return this.csolutionYml.getTargetType(activeTarget); + } + public getActiveTargetSetWrap() { + const activeTargetWrap = this.getActiveTargetTypeWrap(); + if (activeTargetWrap) { + const activeTargetSetIdx = this.cmsisJsonFile.getSelectedSet(activeTargetWrap.name); + return activeTargetWrap.getTargetSetFromIndex(activeTargetSetIdx); + } + return undefined; + } + public getActiveTargetSet(): [TargetType['type'], Optional>] { + const activeTarget = this.getActiveTargetType() ?? ''; + const activeTargetWrap = this.getActiveTargetTypeWrap(); const activeTargetObject = activeTargetWrap?.object; - const activeTargetSetWrap = activeTargetWrap?.getTargetSetFromIndex(activeTargetSetIdx); + const activeTargetSetWrap = this.getActiveTargetSetWrap(); const activeTargetSetObject = activeTargetSetWrap?.object; return [ activeTarget, @@ -398,6 +409,14 @@ export class CSolution { await this.cbuildPackFile.load(cbuildPackPath); } + public getContextNames() { + const targetSet = this.getActiveTargetTypeWrap(); + if (targetSet) { + return targetSet.getContexts(); + } + return []; + } + public getContextDescriptors(): ContextDescriptor[] { return this.cbuildIdxFile.activeContexts; } diff --git a/src/solutions/files/csolution-wrap.ts b/src/solutions/files/csolution-wrap.ts index 8eb1022c3..5de3dbd9f 100644 --- a/src/solutions/files/csolution-wrap.ts +++ b/src/solutions/files/csolution-wrap.ts @@ -281,6 +281,17 @@ export class TargetTypeWrap extends TypedWrap { } return this.getTargetSet(this.targetSetNames.at(idx)); } + + getContexts(targetSetName?: string) : string[] { + const contexts : string[] = []; + const targetSet = this.getTargetSet(targetSetName); + if (targetSet) { + for (const projectContext of targetSet.projectContexts) { + contexts.push(projectContext.name + '+' + this.name); + } + } + return contexts; + } } /** diff --git a/src/solutions/solution-manager.test.ts b/src/solutions/solution-manager.test.ts index 6f2f40c2c..360562186 100644 --- a/src/solutions/solution-manager.test.ts +++ b/src/solutions/solution-manager.test.ts @@ -26,6 +26,9 @@ import { SolutionEventHub, ConvertResultData } from './solution-event-hub'; import { extensionApiProviderFactory } from '../vscode-api/extension-api-provider.factories'; import { EnvironmentManagerApiV1, VcpkgResults } from '@arm-software/vscode-environment-manager'; import { TestDataHandler } from '../__test__/test-data'; +import { Board, Device } from '../json-rpc/csolution-rpc-client'; +import { csolutionServiceFactory } from '../json-rpc/csolution-rpc-client.factory'; +import { SolutionRpcData } from './solution-rpc-data'; const convertResultData: ConvertResultData = { severity: 'success', detection: false }; @@ -52,6 +55,8 @@ describe('SolutionManager', () => { let loadBuildFilesListener: jest.Mock; let tmpSolutionsDir: string; let testSolutionPath: string; + let csolutionService: jest.Mocked>; + let rpcData: SolutionRpcData; const testDataHandler = new TestDataHandler(); @@ -106,10 +111,19 @@ describe('SolutionManager', () => { }; commandsProvider = commandsProviderFactory(); + csolutionService = csolutionServiceFactory(); + const device: Device = { id: 'device-id' }; + const board: Board = { id: 'board-id' }; + csolutionService.getDeviceInfo.mockResolvedValue({ success: true, device }); + csolutionService.getBoardInfo.mockResolvedValue({ success: true, board }); + csolutionService.loadSolution.mockResolvedValue({ success: true }); + csolutionService.getVariables.mockResolvedValue({ success: true, variables: {} }); + rpcData = new SolutionRpcData(csolutionService); solutionManager = new SolutionManagerImpl( mockActiveSolutionTracker as unknown as ActiveSolutionTracker, eventHub, + rpcData, commandsProvider, extensionApiProviderFactory(environmentManagerApi), ); @@ -145,7 +159,7 @@ describe('SolutionManager', () => { await waitTimeout(100); const expectedLoadState: SolutionLoadState = { - solutionPath: testSolutionPath, loaded: true, converted: true, + solutionPath: testSolutionPath, loaded: true, converted: true, activated: true, }; expect(solutionManager.loadState).toEqual(expectedLoadState); @@ -189,7 +203,7 @@ describe('SolutionManager', () => { await waitTimeout(100); const expectedLoadState: SolutionLoadState = { - solutionPath: testSolutionPath, loaded: true, converted: true, + solutionPath: testSolutionPath, loaded: true, converted: true, activated: true, }; expect(solutionManager.loadState).toEqual(expectedLoadState); diff --git a/src/solutions/solution-manager.ts b/src/solutions/solution-manager.ts index b408e4c95..dfe148d6b 100644 --- a/src/solutions/solution-manager.ts +++ b/src/solutions/solution-manager.ts @@ -26,10 +26,12 @@ import { ExtensionApiProvider } from '../vscode-api/extension-api-provider'; import { EnvironmentManagerApiV1 } from '@arm-software/vscode-environment-manager'; import { ETextFileResult } from '../generic/text-file'; import { debounce } from 'lodash'; +import { SolutionRpcData } from './solution-rpc-data'; export interface SolutionLoadState { solutionPath?: string; + activated?: boolean; // solution is activated (loaded and converted at least once) loaded?: boolean; // solution.yml + project.yml files loaded converted?: boolean; // conversion executed and cbuild*.yml files are loaded. }; @@ -37,7 +39,8 @@ export interface SolutionLoadState { export const solutionLoadStatesEqual = (a: SolutionLoadState, b: SolutionLoadState): boolean => { return a.solutionPath === b.solutionPath && a.loaded === b.loaded - && a.converted === b.converted; + && a.converted === b.converted + && a.activated === b.activated; }; export interface SolutionLoadStateChangeEvent { @@ -81,10 +84,12 @@ export class SolutionManagerImpl implements SolutionManager { private readonly debouncedHandleEnvironmentChange = debounce(this.handleEnvironmentChange.bind(this), 500); private _loadState: Readonly = { solutionPath: undefined }; private csolution?: CSolution; + private loadingSolution = false; constructor( private readonly activeSolutionTracker: ActiveSolutionTracker, private readonly eventHub: SolutionEventHub, + private readonly rpcData: SolutionRpcData, private readonly commandsProvider: CommandsProvider, private readonly environmentManagerApiProvider: ExtensionApiProvider>, @@ -97,7 +102,12 @@ export class SolutionManagerImpl implements SolutionManager { this.eventHub.onDidConvertCompleted(this.handleSolutionConvertCompleted, this), this.commandsProvider.registerCommand(SolutionManagerImpl.refreshCommandId, this.refresh, this), this.environmentManagerApiProvider.onActivate(environmentManagerApi => { - environmentManagerApi.onDidActivate(this.debouncedHandleEnvironmentChange, this, context.subscriptions); + environmentManagerApi.onDidActivate(() => { + if (!this.isSolutionActivated()) { + return; + } + this.debouncedHandleEnvironmentChange(); + }, undefined, context.subscriptions); }), this.loadStateChangeEmitter, this.loadBuildFilesEmitter, @@ -118,8 +128,12 @@ export class SolutionManagerImpl implements SolutionManager { return vscode.workspace.getWorkspaceFolder(vscode.Uri.file(solutionPath))?.uri; } + private isSolutionActivated(): boolean { + return !!this.loadState.solutionPath && this.loadState.activated === true; + } + private async handleEnvironmentChange(): Promise { - if (!this.loadState.solutionPath) { + if (!this.isSolutionActivated()) { return; } await this.loadSolution(); @@ -183,11 +197,11 @@ export class SolutionManagerImpl implements SolutionManager { } private async loadSolution(): Promise { - if (!this.loadState.solutionPath) { + if (this.loadingSolution || !this.loadState.solutionPath) { return; } - try { + this.loadingSolution = true; this.csolution = new CSolution(); await this.csolution.load(this.loadState.solutionPath); @@ -199,11 +213,18 @@ export class SolutionManagerImpl implements SolutionManager { this.setLoadState(newState, true); } catch (error) { console.error(`Failed to load ${this.loadState.solutionPath}`, error); + } finally { + this.loadingSolution = false; } } private async handleSolutionConvertCompleted(data: ConvertResultData) { + if (!this.csolution) { + return; + } + await this.rpcData.update(this.csolution); await this.loadSolutionBuildFiles(); + if (data.severity != 'error') { await this.commandsProvider.executeCommandIfRegistered(UPDATE_DEBUG_TASKS_COMMAND_ID); } @@ -216,7 +237,8 @@ export class SolutionManagerImpl implements SolutionManager { const result = await this.csolution.loadBuildFiles(); const newState: SolutionLoadState = { ...this.loadState, - converted: true + activated: true, + converted: true, }; this.setLoadState(newState, result !== ETextFileResult.Unchanged); } diff --git a/src/solutions/solution-rpc-data.test.ts b/src/solutions/solution-rpc-data.test.ts new file mode 100644 index 000000000..b8935b0b5 --- /dev/null +++ b/src/solutions/solution-rpc-data.test.ts @@ -0,0 +1,73 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { csolutionServiceFactory } from '../json-rpc/csolution-rpc-client.factory'; +import { csolutionFactory, CSolutionMock } from './csolution.factory'; +import { SolutionRpcData } from './solution-rpc-data'; + +describe('SolutionRpcData', () => { + let csolutionService: jest.Mocked>; + let rpcData: SolutionRpcData; + let solution: CSolutionMock; + + beforeEach(() => { + csolutionService = csolutionServiceFactory(); + solution = csolutionFactory({ + solutionPath: 'path/to/solution.csolution.yml', + getActiveTargetTypeWrap: jest.fn().mockReturnValue({ name: 'ActiveTarget' }), + getContextNames: jest.fn().mockReturnValue(['ctx']), + }); + rpcData = new SolutionRpcData(csolutionService); + }); + + it('loads context data when loadSolution succeeds', async () => { + csolutionService.loadSolution.mockResolvedValue({ success: true }); + csolutionService.getVariables.mockResolvedValue({ success: true, variables: { FOO: 'bar' } }); + + await rpcData.update(solution); + + expect(csolutionService.loadSolution).toHaveBeenCalledWith({ + solution: solution.solutionPath, + activeTarget: 'ActiveTarget', + }); + expect(csolutionService.getVariables).toHaveBeenCalledWith({ context: 'ctx' }); + expect(rpcData.resolveVariable('ctx', '$FOO$')).toBe('bar'); + expect(rpcData.resolveVariable('ctx', '$MISSING$')).toBeUndefined(); + }); + + it('does not fetch context data when loadSolution fails', async () => { + csolutionService.loadSolution.mockResolvedValue({ success: false }); + + await rpcData.update(solution); + + expect(csolutionService.getVariables).not.toHaveBeenCalled(); + expect(rpcData.resolveVariable('ctx', 'FOO')).toBeUndefined(); + }); + + it('expands $VAR$ placeholders for a context', async () => { + csolutionService.loadSolution.mockResolvedValue({ success: true }); + csolutionService.getVariables.mockResolvedValue({ success: true, variables: { FOO: 'bar', HELLO: 'world' } }); + + await rpcData.update(solution); + + expect(rpcData.expandString('Value: $FOO$ and $HELLO$', 'ctx')).toBe('Value: bar and world'); + }); + + it('returns original string when no variables are available', () => { + expect(rpcData.expandString('plain string', 'ctx')).toBe('plain string'); + }); +}); diff --git a/src/solutions/solution-rpc-data.ts b/src/solutions/solution-rpc-data.ts new file mode 100644 index 000000000..041a53d13 --- /dev/null +++ b/src/solutions/solution-rpc-data.ts @@ -0,0 +1,149 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { constructor } from '../generic/constructor'; +import { Board, CsolutionService, Device, VariablesResult } from '../json-rpc/csolution-rpc-client'; +import { CSolution } from './csolution'; + + + +/** + * Interface to query and cache RPC data + */ +export interface SolutionRpcData { + + /** + * Returns board info data + */ + get board(): Board | undefined; + + /** + * Returns device board info data + */ + get device(): Device | undefined; + + /** Resolves a single variable for a context + * @param context resolving context + * @param variable name without surrounding '$' chars + * @return variable value if resolved, undefined otherwise + */ + resolveVariable(context: string, variable?: string): string | undefined; + /** + * Updates internal data cache + */ + update(solution: CSolution): Promise; + /** + * Expands string for given context substituting variable with corresponding values + * @param context resolving context + * @return expanded string + */ + expandString(str: string, context: string): string +} + +class SolutionRpcDataImpl implements SolutionRpcData { + private readonly contextVariables = new Map>(); + private _board?: Board = undefined; + private _device?: Device = undefined; + + constructor( + private readonly csolutionService: CsolutionService, + ) { + } + clear() { + this.contextVariables.clear(); + this._board = undefined; + this._device = undefined; + } + + public get board(): Board | undefined { + return this._board; + } + + public get device(): Device | undefined { + return this._device; + } + + async update(solution: CSolution): Promise { + this.clear(); + const activeTargetType = solution.getActiveTargetTypeWrap(); + if (!activeTargetType) { + return; + } + const activeTarget = activeTargetType.name; + if (activeTargetType.device) { + const deviceInfo = await this.csolutionService.getDeviceInfo({ id: activeTargetType.device }); + if (deviceInfo.success) { + this._device = deviceInfo.device; + }; + } + if (activeTargetType.board) { + const boardInfo = await this.csolutionService.getBoardInfo({ id: activeTargetType.board }); + if (boardInfo.success) { + this._board = boardInfo.board; + }; + } + + const res = await this.csolutionService.loadSolution( + { + solution: solution.solutionPath, + activeTarget: activeTarget + }); + if (!res.success) { + return; + } + const contexts = solution.getContextNames(); + for (const context of contexts) { + const data = await this.csolutionService.getVariables({ context: context }); + if (data.success) { + this.contextVariables.set(context, this.variablesFromRpcData(data)); + } + } + } + + private variablesFromRpcData(data: VariablesResult) { + const vars = new Map(); + for (const [key, value] of Object.entries(data.variables)) { + vars.set('$' + key + '$', value); + } + return vars; + } + + public resolveVariable(context: string, variable?: string): string | undefined { + if (variable) { + const variables = this.contextVariables.get(context); + if (variables) { + return variables.get(variable); + } + } + return undefined; + } + + + public expandString(str: string, context: string): string { + const variables = this.contextVariables.get(context); + if (!str.includes('$') || !variables || variables.size == 0) { + return str; + } + + let expanded = str; + for (const [name, value] of variables.entries()) { + expanded = expanded.replaceAll(name, value); + } + return expanded; + } +} + +export const SolutionRpcData = constructor(SolutionRpcDataImpl); + diff --git a/src/views/manage-components-packs/view/components/table-renderers/render-validation-row.test.tsx b/src/views/manage-components-packs/view/components/table-renderers/render-validation-row.test.tsx index b93027f16..adb7a1660 100644 --- a/src/views/manage-components-packs/view/components/table-renderers/render-validation-row.test.tsx +++ b/src/views/manage-components-packs/view/components/table-renderers/render-validation-row.test.tsx @@ -10,7 +10,7 @@ import { MockMessageHandler } from '../../../../__test__/mock-message-handler'; import { ComponentRowDataType } from '../../../data/component-tools'; import { IncomingMessage, OutgoingMessage } from '../../../messages'; import { renderValidation } from './render-validation-row'; -import { MessageHandler } from 'src/views/message-handler'; +import { MessageHandler } from '../../../../message-handler'; describe('renderValidation', () => { const makeDependencyNode = (): ComponentRowDataType => ({