From 7fd107499ad04f359313cd8ca10e849e56a0065f Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Mon, 9 Mar 2026 13:47:11 +0100 Subject: [PATCH 1/7] Implement external file change detection and refresh logic in text file management --- src/generic/text-file.factory.ts | 2 + src/generic/text-file.test.ts | 23 +++ src/generic/text-file.ts | 58 ++++++ .../manage-solution-controller.ts | 186 ++++++++++++++++-- .../manage-solution-webview-main.test.ts | 46 +++++ .../manage-solution-webview-main.ts | 21 +- 6 files changed, 316 insertions(+), 20 deletions(-) diff --git a/src/generic/text-file.factory.ts b/src/generic/text-file.factory.ts index eaff38e71..72242204c 100644 --- a/src/generic/text-file.factory.ts +++ b/src/generic/text-file.factory.ts @@ -39,4 +39,6 @@ export const textFileFactory = makeFactory({ clear: () => jest.fn(), exists: () => jest.fn(), unlink: () => jest.fn(), + hasExternalFileChanged: () => jest.fn().mockReturnValue(false), + refreshExternalFileStamp: () => jest.fn(), }, errorListFactory); diff --git a/src/generic/text-file.test.ts b/src/generic/text-file.test.ts index bbae5e3ad..ad0a39a49 100644 --- a/src/generic/text-file.test.ts +++ b/src/generic/text-file.test.ts @@ -370,4 +370,27 @@ describe('TextFile', () => { testDataHandler.rmDir(unicodeDir); }); + + it('primes external stamp on first change check', async () => { + const tf = new TextFile(TEST_FILE); + tf.text = initialContent; + const saveResult = await tf.save(); + expect(saveResult).toBe(ETextFileResult.Success); + + expect(tf.hasExternalFileChanged()).toBe(false); + }); + + it('detects external on-disk updates and then re-baselines', async () => { + const tf = new TextFile(TEST_FILE); + tf.text = initialContent; + const saveResult = await tf.save(); + expect(saveResult).toBe(ETextFileResult.Success); + + tf.refreshExternalFileStamp(); + expect(tf.hasExternalFileChanged()).toBe(false); + + fsUtils.writeTextFile(TEST_FILE, changedContent); + expect(tf.hasExternalFileChanged()).toBe(true); + expect(tf.hasExternalFileChanged()).toBe(false); + }); }); diff --git a/src/generic/text-file.ts b/src/generic/text-file.ts index d78e2f8dc..cc860fd21 100644 --- a/src/generic/text-file.ts +++ b/src/generic/text-file.ts @@ -15,6 +15,7 @@ */ import path from 'node:path'; +import * as fs from 'node:fs'; import * as fsUtils from '../utils/fs-utils'; import * as vscodeUtils from '../utils/vscode-utils'; import { ITextParser } from './text-parser'; @@ -40,6 +41,12 @@ export enum ETextFileResult { NotExists = 3 } +type ExternalFileStamp = { + path: string; + mtimeMs: number; + size: number; +}; + /** * Represents a text file with parsing, rendering, and error handling capabilities. * Provides methods and properties for managing file content, file metadata, and associated parsers/renderers. @@ -170,6 +177,16 @@ export interface ITextFile extends IErrorList { /** Clears file content and errors */ clear(): void; + /** + * Refreshes the stored external file stamp baseline. + */ + refreshExternalFileStamp(): void; + + /** + * Checks whether the file changed externally since the last stamp refresh/check. + */ + hasExternalFileChanged(): boolean; + /** Copy text and parse it to the object, does not change filename * @param src source file */ @@ -194,6 +211,7 @@ export class TextFile extends ErrorList implements ITextFile { protected textRenderer?: ITextRenderer; private _readOnly = false; + private externalFileStamp?: ExternalFileStamp; /** * Constructs a TextFile instance @@ -243,6 +261,7 @@ export class TextFile extends ErrorList implements ITextFile { this._dirty = false; this.contentString = ''; this.contentObject = undefined; + this.externalFileStamp = undefined; this.clearErrors(); } @@ -285,6 +304,7 @@ export class TextFile extends ErrorList implements ITextFile { if (value !== this._fileName) { this._fileName = value; this._fileDir = path.dirname(value); + this.externalFileStamp = undefined; } } @@ -360,6 +380,7 @@ export class TextFile extends ErrorList implements ITextFile { this.fileName = fileName; } const result = this.doLoad(); + this.refreshExternalFileStamp(); this.showErrorMessage(result); return result; } @@ -402,10 +423,47 @@ export class TextFile extends ErrorList implements ITextFile { this._dirty = true; // force saving } const result = this.doSave(); + this.refreshExternalFileStamp(); this.showErrorMessage(result); return result; } + private getCurrentFileStamp(): ExternalFileStamp | undefined { + if (!this.fileName || !fsUtils.fileExists(this.fileName)) { + return undefined; + } + try { + const stat = fs.statSync(this.fileName); + return { + path: this.fileName, + mtimeMs: stat.mtimeMs, + size: stat.size, + }; + } catch { + return undefined; + } + } + + public refreshExternalFileStamp(): void { + this.externalFileStamp = this.getCurrentFileStamp(); + } + + public hasExternalFileChanged(): boolean { + const currentStamp = this.getCurrentFileStamp(); + if (!this.externalFileStamp) { + this.externalFileStamp = currentStamp; + return false; + } + + const changed = !currentStamp + || currentStamp.path !== this.externalFileStamp.path + || currentStamp.mtimeMs !== this.externalFileStamp.mtimeMs + || currentStamp.size !== this.externalFileStamp.size; + + this.externalFileStamp = currentStamp; + return changed; + } + /** * Saves file content to disk * @returns Save result diff --git a/src/views/manage-solution/manage-solution-controller.ts b/src/views/manage-solution/manage-solution-controller.ts index 0a0e25236..576f8a0b2 100644 --- a/src/views/manage-solution/manage-solution-controller.ts +++ b/src/views/manage-solution/manage-solution-controller.ts @@ -44,6 +44,10 @@ export class ManageSolutionController { public customDebugAdapterDefaults: CustomDebugAdapterDefaults = {}; private _csolutionService?: CsolutionService; + /** + * Gets the initialized csolution service instance. + * @throws Error when the service has not been assigned yet. + */ public get csolutionService(): CsolutionService { if (!this._csolutionService) { throw new Error('CsolutionService has not been initialized on ManageSolutionController'); @@ -51,32 +55,56 @@ export class ManageSolutionController { return this._csolutionService; } + /** + * Sets the csolution service used for RPC queries. + */ public set csolutionService(service: CsolutionService) { this._csolutionService = service; } constructor() { } + /** + * Gets the in-memory csolution YAML wrapper. + */ public get csolutionYml() { return this._csolutionYml; } + /** + * Gets the in-memory cmsis.json wrapper. + */ public get cmsisJsonFile() { return this._cmsisJsonFile; } + /** + * Gets the absolute path to the loaded solution file. + */ public get solutionPath() { return this.csolutionYml.fileName; } + /** + * Gets the display name of the loaded solution without extension. + */ public get solutionName() { return getFileNameNoExt(this.solutionPath); } + /** + * Gets the directory that contains the loaded solution file. + */ public get solutionDir() { return this.csolutionYml.fileDir; } + /** + * Loads solution content and associated cmsis.json state. + * @param csolutionPath Optional explicit path to the solution file. + * @param defaultDebugAdapterName Optional default debugger adapter name. + * @returns Result of loading the csolution file. + */ async loadSolution(csolutionPath?: string, defaultDebugAdapterName?: string) { this.defaultDebugAdapterName = defaultDebugAdapterName ?? ''; @@ -118,23 +146,59 @@ export class ManageSolutionController { } } + /** + * Gets the currently active target type wrapper. + */ public get activeTargetTypeWrap(): TargetTypeWrap | undefined { return this.csolutionYml.getTargetType(this.activeTargetTypeName); } + /** + * Sets the active target type name in cmsis.json state. + */ public set activeTargetTypeName(type: string) { this.cmsisJsonFile.activeTargetTypeName = type; } + /** + * Gets the active target type name from cmsis.json, or falls back to the first solution target. + */ public get activeTargetTypeName(): string | undefined { return this.cmsisJsonFile.activeTargetTypeName ?? this.csolutionYml.getTargetType()?.name; } + private async ensureActiveTargetTypeNameInKnownTargets(knownTargetNames: string[]): Promise { + const configuredTargetTypeName = this.cmsisJsonFile.activeTargetTypeName; + if (configuredTargetTypeName && !knownTargetNames.includes(configuredTargetTypeName)) { + this.activeTargetTypeName = this.csolutionYml.getTargetType()?.name || ''; + await this.cmsisJsonFile.save(); + return true; + } + return false; + } + + /** + * Detects external on-disk changes for solution and cmsis.json files + * using file mtime and size stamps. + * + * This complements in-memory dirty checks and helps detect updates done + * outside the current controller instance. + */ + public hasExternalFileChanges(): boolean { + return this.csolutionYml.hasExternalFileChanged() || this.cmsisJsonFile.hasExternalFileChanged(); + } + + /** + * Gets the active target set name for the current active target type. + */ public get activeTargetSetName(): string | undefined { const targetSetIdx = this.cmsisJsonFile.getSelectedSet(this.activeTargetTypeName ?? ''); return this.activeTargetTypeWrap?.getTargetSetFromIndex(targetSetIdx)?.name; }; + /** + * Gets the active target set wrapper and ensures one exists. + */ public get activeTargetSetWrap(): TargetSetWrap { let ts = this.getSelectedSetWrap(this.activeTargetTypeWrap); @@ -144,7 +208,11 @@ export class ManageSolutionController { return ts; } - public getSelectedSetWrap(tt?: TargetTypeWrap): TargetSetWrap | undefined { + /** + * Gets the selected target set wrapper for the given target type. + * @param tt Target type wrapper. + */ + private getSelectedSetWrap(tt?: TargetTypeWrap): TargetSetWrap | undefined { if (!tt) { return undefined; } @@ -152,6 +220,12 @@ export class ManageSolutionController { return tt.getTargetSetFromIndex(targetSetIdx); } + /** + * Sets the active target type/set and updates persisted target set selection. + * @param targetType Target type name. + * @param targetSet Optional target set name. + * @returns True when active selection changed. + */ public setActiveTargetSet(targetType: string, targetSet?: string) { this.csolutionYml.ensureTargetTypeAndSet(targetType, targetSet); const active = this.getActiveTypeAndSetNames(); @@ -169,6 +243,9 @@ export class ManageSolutionController { return true; } + /** + * Gets the currently active target type and target set names. + */ public getActiveTypeAndSetNames(): { type: string, set: string } { const type = this.activeTargetTypeWrap; const set = this.getSelectedSetWrap(type); @@ -179,10 +256,16 @@ export class ManageSolutionController { } // debugger support + /** + * Gets the active debugger wrapper. + */ public get activeDebugger() { return this.activeTargetSetWrap.debugger; } + /** + * Gets the resolved active debugger adapter name. + */ public get activeDebuggerName() { return this.resolvedDebuggerName(this.activeTargetSetWrap.debugger?.name); } @@ -219,6 +302,11 @@ export class ManageSolutionController { return debuggerWrap; } + /** + * Enables or disables debugger configuration on the active target set. + * @param enable True to enable debugger, false to remove it. + * @param name Optional debugger adapter name. + */ public enableDebugger(enable: boolean, name?: string) { if (enable) { this.setSelectedDebugger(name ?? this.defaultDebuggerName); @@ -227,10 +315,18 @@ export class ManageSolutionController { } } + /** + * Checks whether a debugger UI section is enabled. + * @param section Section key in debugger configuration. + */ public isDebuggerSectionEnabled(section: string): boolean { return this.activeTargetSetWrap.ensureDebugger().isSectionEnabled(section); } + /** + * Toggles a debugger section and applies computed defaults when enabling. + * @param section Section key in debugger configuration. + */ public async toggleDebugAdapterSection(section: string) { const adapter = this.debugAdaptersYmlFile?.getAdapterByName(this.activeDebuggerName); this.customizeDebugAdapterDefaults(adapter); @@ -240,11 +336,17 @@ export class ManageSolutionController { this.activeTargetSetWrap.ensureDebugger().toggleSection(section, defaults); } - public get defaultDebuggerName() { + /** + * Gets the default debugger adapter name. + */ + private get defaultDebuggerName() { return this.defaultDebugAdapterName || this.debugAdaptersYmlFile?.debugAdapters[0]?.name || ''; } + /** + * Queries available processor core names for the active target device. + */ public async getAvailableCoreNames(): Promise { const availableCores = this.activeTargetTypeWrap?.device ? (await this.csolutionService.getDeviceInfo({ id: this.activeTargetTypeWrap?.device || '' })).device?.processors?.map((p: { name?: string; core: string }) => p.name || '') || [] @@ -253,10 +355,16 @@ export class ManageSolutionController { return availableCores; } + /** + * Gets cached available processor core names. + */ public get availableCoreNames(): string[] { return this.availableCoreNamesCache; } + /** + * Gets debug adapters, loading and caching them if needed. + */ public get debugAdapters(): Promise { if (this.debugAdaptersCache.length === 0) { return this.refreshDebugAdapters(); @@ -264,6 +372,9 @@ export class ManageSolutionController { return Promise.resolve(this.debugAdaptersCache); } + /** + * Rebuilds debug adapter definitions using current defaults and available cores. + */ public async refreshDebugAdapters(): Promise { const adapters = this.debugAdaptersYmlFile?.debugAdapters ?? []; const availableCores = await this.getAvailableCoreNames(); @@ -279,6 +390,12 @@ export class ManageSolutionController { return adapters; } + /** + * Sets a debugger parameter on the active target set. + * @param section Optional debugger section. + * @param param Parameter key. + * @param value Parameter value. + */ public setDebuggerParameter(section: string | undefined, param: string, value: string | number) { this.activeTargetSetWrap.ensureDebugger().setParameter(section, param, value.toString()); } @@ -329,6 +446,13 @@ export class ManageSolutionController { return defaults; } + /** + * Sets a debugger parameter for a specific processor core (`pname`). + * @param section Optional debugger section. + * @param pname Processor/core name. + * @param param Parameter key. + * @param value Parameter value. + */ public async setDebuggerParameterWithPname(section: string | undefined, pname: string, param: string, value: string | number) { if ((await this.getAvailableCoreNames()).length === 0) { const sequence = this.activeTargetSetWrap.ensureDebugger().item @@ -367,16 +491,49 @@ export class ManageSolutionController { }; } + /** + * Set data from SolutionData object to CSolutionYmlFile + */ + public set solutionData(selectedContextState: SolutionData) { + + this.setActiveTargetSet(selectedContextState.selectedTarget?.name ?? '', selectedContextState.selectedTarget?.selectedSet); + const activeTargetSetWrap = this.activeTargetSetWrap; + this.updateSelectedProjects(activeTargetSetWrap, selectedContextState.projects); + this.updateSelectedImages(activeTargetSetWrap, selectedContextState.images ?? []); + this.updateDebuggerFromSnapshot(selectedContextState); + this.activeTargetSetWrap.purgeImages(); + } + + + + /** + * Ensures active target type in cmsis.json exists in current solution targets. + * @returns True if active target type was corrected and saved. + */ + public async ensureActiveTargetTypeName(): Promise { + const targets: TargetType[] = []; + for (const tt of this.csolutionYml.targetTypes) { + const ttm = this.targetTypeWrapToModel(tt); + targets.push(ttm); + } + return this.ensureActiveTargetTypeNameInKnownTargets(targets.map(t => t.name)); + } + private collectTargets() { const targets: TargetType[] = []; let selectedTarget: TargetType | undefined = undefined; - const selectedTargetName = this.activeTargetTypeName; for (const tt of this.csolutionYml.targetTypes) { const ttm = this.targetTypeWrapToModel(tt); targets.push(ttm); - if (tt.name === selectedTargetName) { - selectedTarget = ttm; - } + } + + const selectedTargetName = this.activeTargetTypeName; + if (selectedTargetName) { + selectedTarget = targets.find(target => target.name === selectedTargetName); + } + + if (!selectedTarget) { + selectedTarget = targets[0]; } return { targets, selectedTarget }; } @@ -460,19 +617,6 @@ export class ManageSolutionController { }; } - /** - * Set data from SolutionData object to CSolutionYmlFile - */ - public set solutionData(selectedContextState: SolutionData) { - - this.setActiveTargetSet(selectedContextState.selectedTarget?.name ?? '', selectedContextState.selectedTarget?.selectedSet); - const activeTargetSetWrap = this.activeTargetSetWrap; - this.updateSelectedProjects(activeTargetSetWrap, selectedContextState.projects); - this.updateSelectedImages(activeTargetSetWrap, selectedContextState.images ?? []); - this.updateDebuggerFromSnapshot(selectedContextState); - this.activeTargetSetWrap.purgeImages(); - } - private updateDebuggerFromSnapshot(selectedContextState: SolutionData): void { const targetName = selectedContextState.selectedTarget?.name; const targetModel = selectedContextState.targets.find(t => t.name === targetName); @@ -541,6 +685,10 @@ export class ManageSolutionController { return da; } + /** + * Sets selected debugger and initializes top-level debugger defaults. + * @param name Debugger adapter name or alias. + */ public setSelectedDebugger(name: string) { const adapter = this.debugAdaptersYmlFile?.getAdapterByName(name); this.customizeDebugAdapterDefaults(adapter); diff --git a/src/views/manage-solution/manage-solution-webview-main.test.ts b/src/views/manage-solution/manage-solution-webview-main.test.ts index 300806646..afc363ca4 100644 --- a/src/views/manage-solution/manage-solution-webview-main.test.ts +++ b/src/views/manage-solution/manage-solution-webview-main.test.ts @@ -245,6 +245,48 @@ describe('ContextSelectionWebviewMain', () => { }); describe('onDidChangeLoadState callback', () => { + it('reloads solution before active target validation when external files changed', async () => { + const solutionManager = solutionManagerFactory(); + const main = manageSolutionWebviewMainFactory({ + solutionManager, + webviewManager + }); + + (main as any).webviewManager.isPanelActive = true; + + const callOrder: string[] = []; + const loadSolutionSpy = jest.spyOn(main as any, 'loadSolution').mockImplementation(async () => { + callOrder.push('loadSolution'); + return ETextFileResult.Success; + }); + const sendContextDataSpy = jest.spyOn(main as any, 'sendContextData').mockResolvedValue(undefined); + + const hasExternalFileChangesSpy = jest.spyOn(main.controller, 'hasExternalFileChanges').mockImplementation(() => { + callOrder.push('hasExternalFileChanges'); + return true; + }); + + const ensureActiveTargetTypeNameSpy = jest.spyOn(main.controller, 'ensureActiveTargetTypeName').mockImplementation(async () => { + callOrder.push('ensureActiveTargetTypeName'); + return false; + }); + + await (main as any).handleSolutionLoadChange({ + previousState: { solutionPath: '/path/to/solution.csolution.yml', converted: true, loaded: true }, + newState: { solutionPath: '/path/to/solution.csolution.yml', converted: true, loaded: true } + }); + + expect(hasExternalFileChangesSpy).toHaveBeenCalledTimes(1); + expect(loadSolutionSpy).toHaveBeenCalledTimes(1); + expect(ensureActiveTargetTypeNameSpy).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual([ + 'hasExternalFileChanges', + 'loadSolution', + 'ensureActiveTargetTypeName', + ]); + expect(sendContextDataSpy).toHaveBeenCalledTimes(1); + }); + it('calls sendContextData when transitioning to active state', async () => { const solutionManager = solutionManagerFactory(); const main = manageSolutionWebviewMainFactory({ @@ -263,6 +305,8 @@ describe('ContextSelectionWebviewMain', () => { { converted: true, loaded: false, solutionPath: '/path/to/solution.csolution.yml' } ); + await waitTimeout(); + expect(sendContextDataSpy).toHaveBeenCalled(); expect(webviewManager.sendMessage).toHaveBeenCalledWith({ type: 'IS_BUSY', data: true }); }); @@ -287,6 +331,8 @@ describe('ContextSelectionWebviewMain', () => { solutionManager.onDidChangeLoadStateEmitter!.fire(event); + await waitTimeout(); + expect(sendContextDataSpy).toHaveBeenCalled(); }); diff --git a/src/views/manage-solution/manage-solution-webview-main.ts b/src/views/manage-solution/manage-solution-webview-main.ts index 689f19734..b5b22dd4b 100644 --- a/src/views/manage-solution/manage-solution-webview-main.ts +++ b/src/views/manage-solution/manage-solution-webview-main.ts @@ -93,14 +93,33 @@ export class ManageSolutionWebviewMain { this.setBusyState(true); + let csolutionChanged = false; if (newPath !== prevPath) { if (newPath) { - await this.sendContextData(); + csolutionChanged = true; } else if (prevPath) { await this.clearContext(); this.webviewManager.disposePanel(); + this.setBusyState(false); + return; } } else if (newLoaded !== prevLoaded) { + csolutionChanged = true; + } + + const externalFilesChanged = csolutionChanged ? false : this.controller.hasExternalFileChanges(); + + if (csolutionChanged || externalFilesChanged) { + const result = await this.loadSolution(); + if (result === ETextFileResult.Error || result === ETextFileResult.NotExists) { + this.setBusyState(false); + return; + } + } + + const activeTargetTypeUpdated = await this.controller.ensureActiveTargetTypeName(); + + if (csolutionChanged || externalFilesChanged || activeTargetTypeUpdated) { await this.sendContextData(); } From d80cfc38bee3505159143ddc9c7af58082cec81e Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Tue, 10 Mar 2026 10:53:48 +0100 Subject: [PATCH 2/7] Add activated state handling in solution load change events --- src/solutions/solution-manager.factories.ts | 2 ++ .../manage-solution-webview-main.ts | 32 ++++--------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/solutions/solution-manager.factories.ts b/src/solutions/solution-manager.factories.ts index a2c4c4818..f98da09ff 100644 --- a/src/solutions/solution-manager.factories.ts +++ b/src/solutions/solution-manager.factories.ts @@ -28,12 +28,14 @@ export const idleSolutionLoadStateFactory = makeFactory({ solutionPath: () => undefined, loaded: () => undefined, converted: () => undefined, + activated: () => undefined, }); export const activeSolutionLoadStateFactory = makeFactory({ solutionPath: () => path.join(faker.system.filePath(), `${faker.word.noun()}.csolution.yml`), loaded: () => undefined, converted: () => undefined, + activated: () => undefined, }); const fireOnDidChangeLoadState = (emitter: vscode.EventEmitter) => { diff --git a/src/views/manage-solution/manage-solution-webview-main.ts b/src/views/manage-solution/manage-solution-webview-main.ts index b5b22dd4b..f1a5c5bcc 100644 --- a/src/views/manage-solution/manage-solution-webview-main.ts +++ b/src/views/manage-solution/manage-solution-webview-main.ts @@ -84,8 +84,8 @@ export class ManageSolutionWebviewMain { } private async handleSolutionLoadChange(e: SolutionLoadStateChangeEvent): Promise { - const { solutionPath: newPath, converted: newConverted, loaded: newLoaded } = e.newState; - const { solutionPath: prevPath, converted: prevConverted, loaded: prevLoaded } = e.previousState; + const { solutionPath: newPath, converted: newConverted, loaded: newLoaded, activated: newActivated } = e.newState; + const { solutionPath: prevPath, converted: prevConverted, loaded: prevLoaded, activated: prevActivated } = e.previousState; if (!this.webviewManager.isPanelActive || (newPath === prevPath && newConverted !== prevConverted)) { return; @@ -103,6 +103,8 @@ export class ManageSolutionWebviewMain { this.setBusyState(false); return; } + } else if (newActivated !== prevActivated) { + csolutionChanged = true; } else if (newLoaded !== prevLoaded) { csolutionChanged = true; } @@ -169,10 +171,6 @@ export class ManageSolutionWebviewMain { return dirname(this.controller?.solutionPath ?? ''); } - private getSolutionBasename(): string { - return path.basename(this.controller?.solutionPath ?? '') || 'the solution'; - } - public attachToPanel(panel: vscode.WebviewPanel): void { this.webviewManager.attachPanel(panel); } @@ -323,8 +321,8 @@ export class ManageSolutionWebviewMain { await this.updateDebuggerParameter('', 'start-pname', name); } - public async saveChanges(): Promise { - if (!this.isDirty) { + public async saveChanges(force: boolean = false): Promise { + if (!this.isDirty && !force) { return; } await this.setBusyState(true); @@ -347,23 +345,6 @@ export class ManageSolutionWebviewMain { }); } - protected async querySaveModified(): Promise { - if (!this.isDirty) { - return; - } - // for now only yes/no answers are supported, cancel can be only triggered externally when changing solution - // todo: query all modifications from this and component views - const result = await vscode.window.showWarningMessage( - `Manage Solution: You have unsaved changes in ${this.getSolutionBasename()}. Do you want to save them?`, - { modal: true }, - 'Yes', - 'No' - ); - if (result === 'Yes') { - await this.saveChanges(); - } - } - /** * Loads csolution.ym file for editing * @returns true if solution file is successfully loaded @@ -371,7 +352,6 @@ export class ManageSolutionWebviewMain { protected async loadSolution(): Promise { const globalSolution = this.solutionManager.getCsolution(); // get global csolution if (this.controller.solutionPath !== globalSolution?.solutionPath) { - // await this.querySaveModified(); this._controller = this.createController(); // todo: use clear instead } if (!globalSolution) { // no solution is loaded in workspace From e7df0e5b3daa8e686fee98fa529e8c809b54aaae Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Thu, 12 Mar 2026 09:42:17 +0100 Subject: [PATCH 3/7] Add delay to ensure external file change detection in tests --- src/generic/text-file.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/generic/text-file.test.ts b/src/generic/text-file.test.ts index ad0a39a49..236ed0f0a 100644 --- a/src/generic/text-file.test.ts +++ b/src/generic/text-file.test.ts @@ -390,6 +390,8 @@ describe('TextFile', () => { expect(tf.hasExternalFileChanged()).toBe(false); fsUtils.writeTextFile(TEST_FILE, changedContent); + await new Promise(resolve => setTimeout(resolve, 500)); + expect(tf.hasExternalFileChanged()).toBe(true); expect(tf.hasExternalFileChanged()).toBe(false); }); From c61e7f2a4e4a90aedc933935dafae411c65466f4 Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Thu, 12 Mar 2026 11:08:42 +0100 Subject: [PATCH 4/7] Rename sendContextData to sendContextDataFromControllerState for clarity and update references in tests --- .../manage-solution-webview-main.test.ts | 18 +++++++++--------- .../manage-solution-webview-main.ts | 6 +++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/views/manage-solution/manage-solution-webview-main.test.ts b/src/views/manage-solution/manage-solution-webview-main.test.ts index afc363ca4..d9ff656d8 100644 --- a/src/views/manage-solution/manage-solution-webview-main.test.ts +++ b/src/views/manage-solution/manage-solution-webview-main.test.ts @@ -259,7 +259,7 @@ describe('ContextSelectionWebviewMain', () => { callOrder.push('loadSolution'); return ETextFileResult.Success; }); - const sendContextDataSpy = jest.spyOn(main as any, 'sendContextData').mockResolvedValue(undefined); + const sendContextDataFromControllerStateSpy = jest.spyOn(main as any, 'sendContextDataFromControllerState').mockResolvedValue(undefined); const hasExternalFileChangesSpy = jest.spyOn(main.controller, 'hasExternalFileChanges').mockImplementation(() => { callOrder.push('hasExternalFileChanges'); @@ -284,10 +284,10 @@ describe('ContextSelectionWebviewMain', () => { 'loadSolution', 'ensureActiveTargetTypeName', ]); - expect(sendContextDataSpy).toHaveBeenCalledTimes(1); + expect(sendContextDataFromControllerStateSpy).toHaveBeenCalledTimes(1); }); - it('calls sendContextData when transitioning to active state', async () => { + it('calls sendContextDataFromControllerState when transitioning to active state', async () => { const solutionManager = solutionManagerFactory(); const main = manageSolutionWebviewMainFactory({ solutionManager, @@ -295,8 +295,8 @@ describe('ContextSelectionWebviewMain', () => { }); (main as any).webviewManager.isPanelActive = jest.fn().mockReturnValue(true); // Simulate active panel - // Spy on sendContextData to verify it gets called - const sendContextDataSpy = jest.spyOn(main as any, 'sendContextData').mockResolvedValue(undefined); + // Spy on broadcast helper to verify it gets called + const sendContextDataFromControllerStateSpy = jest.spyOn(main as any, 'sendContextDataFromControllerState').mockResolvedValue(undefined); await main.activate(context as vscode.ExtensionContext); @@ -307,11 +307,11 @@ describe('ContextSelectionWebviewMain', () => { await waitTimeout(); - expect(sendContextDataSpy).toHaveBeenCalled(); + expect(sendContextDataFromControllerStateSpy).toHaveBeenCalled(); expect(webviewManager.sendMessage).toHaveBeenCalledWith({ type: 'IS_BUSY', data: true }); }); - it('calls sendContextData when transitioning from idle to active', async () => { + it('calls sendContextDataFromControllerState when transitioning from idle to active', async () => { const solutionManager = solutionManagerFactory(); const main = manageSolutionWebviewMainFactory({ solutionManager, @@ -319,7 +319,7 @@ describe('ContextSelectionWebviewMain', () => { }); (main as any).webviewManager.isPanelActive = jest.fn().mockReturnValue(true); // Simulate active panel - const sendContextDataSpy = jest.spyOn(main as any, 'sendContextData').mockResolvedValue(undefined); + const sendContextDataFromControllerStateSpy = jest.spyOn(main as any, 'sendContextDataFromControllerState').mockResolvedValue(undefined); await main.activate(context as vscode.ExtensionContext); @@ -333,7 +333,7 @@ describe('ContextSelectionWebviewMain', () => { await waitTimeout(); - expect(sendContextDataSpy).toHaveBeenCalled(); + expect(sendContextDataFromControllerStateSpy).toHaveBeenCalled(); }); it('does call sendContextData when transitioning from active to idle', async () => { diff --git a/src/views/manage-solution/manage-solution-webview-main.ts b/src/views/manage-solution/manage-solution-webview-main.ts index f1a5c5bcc..357db20f3 100644 --- a/src/views/manage-solution/manage-solution-webview-main.ts +++ b/src/views/manage-solution/manage-solution-webview-main.ts @@ -122,7 +122,7 @@ export class ManageSolutionWebviewMain { const activeTargetTypeUpdated = await this.controller.ensureActiveTargetTypeName(); if (csolutionChanged || externalFilesChanged || activeTargetTypeUpdated) { - await this.sendContextData(); + await this.sendContextDataFromControllerState(); } this.setBusyState(false); @@ -381,6 +381,10 @@ export class ManageSolutionWebviewMain { return; } + await this.sendContextDataFromControllerState(); + } + + private async sendContextDataFromControllerState(): Promise { const controller = this.controller; await controller.getAvailableCoreNames(); await Promise.all([ From bbec7c35eb3efa000eb6df2a41dea453a142d9ea Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Thu, 12 Mar 2026 11:14:54 +0100 Subject: [PATCH 5/7] Improve active target type handling in ManageSolutionController and update related tests --- src/generic/text-file.test.ts | 13 ++++++----- .../manage-solution-controller.test.ts | 22 +++++++++++++++++++ .../manage-solution-controller.ts | 21 +++++++++++++----- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/generic/text-file.test.ts b/src/generic/text-file.test.ts index 236ed0f0a..4aa9f7982 100644 --- a/src/generic/text-file.test.ts +++ b/src/generic/text-file.test.ts @@ -371,13 +371,16 @@ describe('TextFile', () => { testDataHandler.rmDir(unicodeDir); }); - it('primes external stamp on first change check', async () => { + it('primes external stamp when checked before load/save', async () => { + fsUtils.writeTextFile(TEST_FILE, initialContent); const tf = new TextFile(TEST_FILE); - tf.text = initialContent; - const saveResult = await tf.save(); - expect(saveResult).toBe(ETextFileResult.Success); expect(tf.hasExternalFileChanged()).toBe(false); + + fsUtils.writeTextFile(TEST_FILE, `${changedContent}!`); + await new Promise(resolve => setTimeout(resolve, 500)); + + expect(tf.hasExternalFileChanged()).toBe(true); }); it('detects external on-disk updates and then re-baselines', async () => { @@ -389,7 +392,7 @@ describe('TextFile', () => { tf.refreshExternalFileStamp(); expect(tf.hasExternalFileChanged()).toBe(false); - fsUtils.writeTextFile(TEST_FILE, changedContent); + fsUtils.writeTextFile(TEST_FILE, `${changedContent}!`); await new Promise(resolve => setTimeout(resolve, 500)); expect(tf.hasExternalFileChanged()).toBe(true); diff --git a/src/views/manage-solution/manage-solution-controller.test.ts b/src/views/manage-solution/manage-solution-controller.test.ts index f4b802331..407879208 100644 --- a/src/views/manage-solution/manage-solution-controller.test.ts +++ b/src/views/manage-solution/manage-solution-controller.test.ts @@ -145,6 +145,28 @@ describe('manage-solution-controller', () => { expect(controller.activeTargetTypeName).toBe(initialName); }); + it('should recover from persisted empty active target type using first known target', async () => { + const controller = new ManageSolutionController(); + await controller.loadSolution('test-resources/solutions/solution-with-debuggers.csolution'); + + controller.activeTargetTypeName = ''; + const updated = await controller.ensureActiveTargetTypeName(); + + expect(updated).toBe(true); + expect(controller.activeTargetTypeName).toBe(controller.solutionData.targets[0].name); + }); + + it('should clear persisted invalid active target type when no target types are available', async () => { + const controller = new ManageSolutionController(); + await controller.loadSolution('test-resources/solutions/solution-with-debuggers.csolution'); + + controller.activeTargetTypeName = 'invalid-target'; + const updated = await (controller as any).ensureActiveTargetTypeNameInKnownTargets([]); + + expect(updated).toBe(true); + expect(controller.cmsisJsonFile.activeTargetTypeName).toBeUndefined(); + }); + it('should get active target set name', async () => { const controller = new ManageSolutionController(); await controller.loadSolution('test-resources/solutions/solution-with-debuggers.csolution'); diff --git a/src/views/manage-solution/manage-solution-controller.ts b/src/views/manage-solution/manage-solution-controller.ts index 576f8a0b2..c3a3c976a 100644 --- a/src/views/manage-solution/manage-solution-controller.ts +++ b/src/views/manage-solution/manage-solution-controller.ts @@ -169,12 +169,23 @@ export class ManageSolutionController { private async ensureActiveTargetTypeNameInKnownTargets(knownTargetNames: string[]): Promise { const configuredTargetTypeName = this.cmsisJsonFile.activeTargetTypeName; - if (configuredTargetTypeName && !knownTargetNames.includes(configuredTargetTypeName)) { - this.activeTargetTypeName = this.csolutionYml.getTargetType()?.name || ''; - await this.cmsisJsonFile.save(); - return true; + const hasConfiguredTargetType = configuredTargetTypeName !== undefined; + const configuredTargetInvalid = configuredTargetTypeName === '' + || (configuredTargetTypeName !== undefined && !knownTargetNames.includes(configuredTargetTypeName)); + + if (!hasConfiguredTargetType || !configuredTargetInvalid) { + return false; + } + + const fallbackTargetTypeName = knownTargetNames[0]; + if (fallbackTargetTypeName) { + this.activeTargetTypeName = fallbackTargetTypeName; + } else { + this.cmsisJsonFile.delete(`targetSet.${this.cmsisJsonFile.solutionDisplayName}.activeTargetType`); } - return false; + + await this.cmsisJsonFile.save(); + return true; } /** From cc7a5564c325f0f2a4e2e35b7ad5bec0637d09f3 Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Thu, 12 Mar 2026 11:28:36 +0100 Subject: [PATCH 6/7] Refactor ensureActiveTargetTypeNameInKnownTargets access in manage-solution-controller tests --- src/views/manage-solution/manage-solution-controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/manage-solution/manage-solution-controller.test.ts b/src/views/manage-solution/manage-solution-controller.test.ts index 407879208..7faf650ca 100644 --- a/src/views/manage-solution/manage-solution-controller.test.ts +++ b/src/views/manage-solution/manage-solution-controller.test.ts @@ -161,7 +161,7 @@ describe('manage-solution-controller', () => { await controller.loadSolution('test-resources/solutions/solution-with-debuggers.csolution'); controller.activeTargetTypeName = 'invalid-target'; - const updated = await (controller as any).ensureActiveTargetTypeNameInKnownTargets([]); + const updated = await controller['ensureActiveTargetTypeNameInKnownTargets']([]); expect(updated).toBe(true); expect(controller.cmsisJsonFile.activeTargetTypeName).toBeUndefined(); From bbee525a918252bdbb46892037083e6767e31dd7 Mon Sep 17 00:00:00 2001 From: Arne Schmid Date: Tue, 17 Mar 2026 09:21:13 +0100 Subject: [PATCH 7/7] Fix: update test to spy on sendContextDataFromControllerState instead of sendContextData --- .../manage-solution/manage-solution-webview-main.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/views/manage-solution/manage-solution-webview-main.test.ts b/src/views/manage-solution/manage-solution-webview-main.test.ts index b672bc5d7..1b5eafea5 100644 --- a/src/views/manage-solution/manage-solution-webview-main.test.ts +++ b/src/views/manage-solution/manage-solution-webview-main.test.ts @@ -319,7 +319,8 @@ describe('ContextSelectionWebviewMain', () => { }); (main as any).webviewManager.isPanelActive = jest.fn().mockReturnValue(true); - const sendContextDataSpy = jest.spyOn(main as any, 'sendContextData').mockResolvedValue(undefined); + // Spy on broadcast helper to verify it gets called + const sendContextDataFromControllerStateSpy = jest.spyOn(main as any, 'sendContextDataFromControllerState').mockResolvedValue(undefined); await main.activate(context as vscode.ExtensionContext); @@ -330,7 +331,7 @@ describe('ContextSelectionWebviewMain', () => { await waitTimeout(); - expect(sendContextDataSpy).toHaveBeenCalled(); + expect(sendContextDataFromControllerStateSpy).toHaveBeenCalled(); expect(webviewManager.sendMessage).toHaveBeenCalledWith({ type: 'IS_BUSY', data: true }); });