diff --git a/__mocks__/vscode.js b/__mocks__/vscode.js index bf4fb789..fe469696 100644 --- a/__mocks__/vscode.js +++ b/__mocks__/vscode.js @@ -30,6 +30,11 @@ const StatusBarAlignment = { Right: 2 }; +const ConfigurationTarget = { + Global: 1, + Workspace: 2, +}; + const MockTreeItemCollapsibleState = { None: 0, Collapsed: 1, @@ -117,6 +122,8 @@ module.exports = { workspace: { getConfiguration: jest.fn(() => ({ get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + inspect: jest.fn().mockReturnValue(undefined), })), fs: { readFile: jest.fn(uri => { @@ -162,4 +169,5 @@ module.exports = { }, EnvironmentVariableMutatorType, StatusBarAlignment, + ConfigurationTarget, }; diff --git a/package.json b/package.json index cc5874f8..d5a04205 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,11 @@ ] }, "commands": [ + { + "command": "vscode-cmsis-debugger.resetDynamicViewState", + "title": "Reset All Dynamic View States", + "category": "CMSIS Debugger" + }, { "command": "vscode-cmsis-debugger.openDisassemblyView", "title": "Open Disassembly View", @@ -178,11 +183,13 @@ { "command": "vscode-cmsis-debugger.componentViewer.enablePeriodicUpdate", "title": "Enable Periodic Update", + "icon": "$(sync)", "category": "Component Viewer" }, { "command": "vscode-cmsis-debugger.componentViewer.disablePeriodicUpdate", "title": "Disable Periodic Update", + "icon": "$(sync-ignored)", "category": "Component Viewer" }, { @@ -218,11 +225,13 @@ { "command": "vscode-cmsis-debugger.corePeripherals.enablePeriodicUpdate", "title": "Enable Periodic Update", + "icon": "$(sync)", "category": "Core Peripherals" }, { "command": "vscode-cmsis-debugger.corePeripherals.disablePeriodicUpdate", "title": "Disable Periodic Update", + "icon": "$(sync-ignored)", "category": "Core Peripherals" }, { @@ -274,6 +283,10 @@ } ], "commandPalette": [ + { + "command": "vscode-cmsis-debugger.resetDynamicViewState", + "when": "true" + }, { "command": "vscode-cmsis-debugger.openDisassemblyView", "when": "false" @@ -414,12 +427,12 @@ { "command": "vscode-cmsis-debugger.componentViewer.expandAll", "when": "view == cmsis-debugger.componentViewer", - "group": "navigation@3" + "group": "navigation@4" }, { "command": "vscode-cmsis-debugger.corePeripherals.expandAll", "when": "view == cmsis-debugger.corePeripherals", - "group": "navigation@3" + "group": "navigation@4" }, { "command": "vscode-cmsis-debugger.componentViewer.filterTree", @@ -440,6 +453,26 @@ "command": "vscode-cmsis-debugger.corePeripherals.clearFilter", "when": "view == cmsis-debugger.corePeripherals && corePeripherals.filterActive", "group": "navigation@2" + }, + { + "command": "vscode-cmsis-debugger.componentViewer.disablePeriodicUpdate", + "when": "view == cmsis-debugger.componentViewer && componentViewer.periodicUpdateEnabled", + "group": "navigation@3" + }, + { + "command": "vscode-cmsis-debugger.componentViewer.enablePeriodicUpdate", + "when": "view == cmsis-debugger.componentViewer && !componentViewer.periodicUpdateEnabled", + "group": "navigation@3" + }, + { + "command": "vscode-cmsis-debugger.corePeripherals.disablePeriodicUpdate", + "when": "view == cmsis-debugger.corePeripherals && corePeripherals.periodicUpdateEnabled", + "group": "navigation@3" + }, + { + "command": "vscode-cmsis-debugger.corePeripherals.enablePeriodicUpdate", + "when": "view == cmsis-debugger.corePeripherals && !corePeripherals.periodicUpdateEnabled", + "group": "navigation@3" } ], "view/item/context": [ @@ -556,7 +589,44 @@ } } } - ] + ], + "configuration": { + "title": "CMSIS Debugger", + "properties": { + "vscode-cmsis-debugger.viewState": { + "type": "object", + "markdownDescription": "Persisted dynamic view state per debug configuration.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "componentViewer": { + "type": "object", + "markdownDescription": "Persisted dynamic view state for the Component Viewer.", + "additionalProperties": false, + "properties": { + "periodicUpdateEnabled": { "type": "boolean" }, + "filterPattern": { "type": "string" } + } + }, + "corePeripherals": { + "type": "object", + "markdownDescription": "Persisted dynamic view state for the Core Peripherals view.", + "additionalProperties": false, + "properties": { + "periodicUpdateEnabled": { "type": "boolean" }, + "filterPattern": { "type": "string" } + } + }, + "cpuStates": { + "type": "boolean", + "markdownDescription": "Persisted CPU time enable/disable state." + } + } + } + } + } + } }, "scripts": { "prepare": "npm run build", diff --git a/src/cbuild-run/cbuild-run-reader.ts b/src/cbuild-run/cbuild-run-reader.ts index 00b2d323..463bbee4 100644 --- a/src/cbuild-run/cbuild-run-reader.ts +++ b/src/cbuild-run/cbuild-run-reader.ts @@ -108,4 +108,7 @@ export class CbuildRunReader { return pnameProcessors.map(p => p.pname!); } + public getTargetType(): string | undefined { + return this.cbuildRun?.['target-type']; + } } diff --git a/src/debug-session/__test__/debug-session.factory.ts b/src/debug-session/__test__/debug-session.factory.ts index 7b5065f4..2a418e7c 100644 --- a/src/debug-session/__test__/debug-session.factory.ts +++ b/src/debug-session/__test__/debug-session.factory.ts @@ -20,8 +20,9 @@ import { GDBTargetDebugSession, GDBTargetDebugTracker, TargetState } from '..'; export type OnRefreshCallback = (session: Session) => void; export type Session = { - session: { id: string }; - getCbuildRun: () => Promise<{ getScvdFilePaths: () => string[] } | undefined>; + session: { id: string; configuration?: { name: string } }; + getCbuildRun: () => Promise<{ getScvdFilePaths: () => string[]; getTargetType: () => string | undefined } | undefined>; + getConfigStateKey: () => Promise; getPname: () => Promise; refreshTimer: { onRefresh: (cb: OnRefreshCallback) => void }; targetState?: TargetState; @@ -88,11 +89,13 @@ export const debugSessionFactory = ( // Ensure same object returned for multiple calls to getCbuildRun. const cbuildRunMock = hasCbuildRun ? { getContents: jest.fn(), - getScvdFilePaths: () => paths + getScvdFilePaths: () => paths, + getTargetType: jest.fn(() => undefined), } : undefined; return { - session: { id }, - getCbuildRun: async () => cbuildRunMock, + session: { id, configuration: { name: id } }, + getCbuildRun: jest.fn().mockResolvedValue(cbuildRunMock), + getConfigStateKey: jest.fn().mockResolvedValue(id), getPname: async () => pname, refreshTimer: { onRefresh: jest.fn(), diff --git a/src/debug-session/gdbtarget-debug-session.ts b/src/debug-session/gdbtarget-debug-session.ts index 3934017b..4d256ab5 100644 --- a/src/debug-session/gdbtarget-debug-session.ts +++ b/src/debug-session/gdbtarget-debug-session.ts @@ -109,6 +109,13 @@ export class GDBTargetDebugSession { return pname; } + public async getConfigStateKey(): Promise { + const cbuildRun = await this.getCbuildRun(); + const targetType = cbuildRun?.getTargetType(); + const configStateKey = targetType ? `${targetType}::${this.session.configuration.name}` : this.session.configuration.name; + return configStateKey; + } + /** * Check if first stop attempt for session is done by 'terminate' request. * Notes: diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index f94e8b52..e25ccf7d 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -27,6 +27,7 @@ import { GenericCommands } from '../features/generic-commands'; import { ComponentViewer } from '../views/component-viewer/component-viewer'; import { ComponentViewerTreeDataProvider } from '../views/component-viewer/component-viewer-tree-view'; import { CorePeripherals } from '../views/core-peripherals/core-peripherals'; +import { clearAllViewState } from '../views/dynamic-view-states'; const BUILTIN_TOOLS_PATHS = [ 'tools/pyocd/pyocd', @@ -85,6 +86,18 @@ export const activate = async (context: vscode.ExtensionContext): Promise canCompleteActivation = false; } + // Register reset dynamic view state command + context.subscriptions.push( + vscode.commands.registerCommand('vscode-cmsis-debugger.resetDynamicViewState', async () => { + await clearAllViewState(); + await Promise.all([ + cpuStates.resetViewState(), + componentViewer.resetViewState(), + corePeripherals.resetViewState(), + ]); + }) + ); + if (!canCompleteActivation) { logger.debug('CMSIS Debugger activation incomplete'); // Let promise float, we reload the window. diff --git a/src/features/cpu-states/cpu-states-statusbar-item.ts b/src/features/cpu-states/cpu-states-statusbar-item.ts index c26e6efc..7b9ff76b 100644 --- a/src/features/cpu-states/cpu-states-statusbar-item.ts +++ b/src/features/cpu-states/cpu-states-statusbar-item.ts @@ -66,6 +66,7 @@ export class CpuStatesStatusBarItem { } protected async handleItemCommand(): Promise { + const cpuEnabled = this.cpuStates?.isEnabled ?? true; const items: QuickPickHandlerItem[] = [ { label: 'CPU Time', @@ -76,7 +77,18 @@ export class CpuStatesStatusBarItem { label: 'Reset CPU Time', detail: 'Reset CPU execution time and history', handler: () => vscode.commands.executeCommand(CpuStatesCommands.resetCpuTimeHistoryID) - } + }, + cpuEnabled + ? { + label: 'Disable CPU Timer', + detail: 'Stop collecting CPU execution time', + handler: () => vscode.commands.executeCommand(CpuStatesCommands.disableCpuTimer) + } + : { + label: 'Enable CPU Timer', + detail: 'Start collecting CPU execution time', + handler: () => vscode.commands.executeCommand(CpuStatesCommands.enableCpuTimer) + } ]; const selection = await vscode.window.showQuickPick(items); if (!selection) { diff --git a/src/features/cpu-states/cpu-states.test.ts b/src/features/cpu-states/cpu-states.test.ts index 81834d79..5168f816 100644 --- a/src/features/cpu-states/cpu-states.test.ts +++ b/src/features/cpu-states/cpu-states.test.ts @@ -443,13 +443,13 @@ describe('CpuStates', () => { it('enable cpu states sets enableCpuStates flag to true', async () => { cpuStates.activate(tracker); await cpuStates.enableCpuStates(); - expect((cpuStates as unknown as { enableCpuStatesFlag: boolean }).enableCpuStatesFlag).toEqual(true); + expect(cpuStates.activeCpuStates?.enableCpuStatesFlag).toEqual(true); }); it('disable cpu states sets enableCpuStates flag to false', async () => { cpuStates.activate(tracker); await cpuStates.disableCpuStates(); - expect((cpuStates as unknown as { enableCpuStatesFlag: boolean }).enableCpuStatesFlag).toEqual(false); + expect(cpuStates.activeCpuStates?.enableCpuStatesFlag).toEqual(false); }); }); @@ -488,5 +488,89 @@ describe('CpuStates', () => { }); + describe('CPU timer enable/disable state persists to and restores from settings', () => { + beforeEach(() => { + jest.spyOn(gdbtargetDebugSession, 'getCbuildRun').mockResolvedValue({ + getTargetType: () => 'My-Target', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('restores CPU timer enabled state from settings on connect', async () => { + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + inspect: jest.fn().mockReturnValue({ + globalValue: { + 'My-Target::Debug': { + cpuStates: false, + }, + }, + workspaceValue: {}, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + cpuStates.activate(tracker); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onWillStartSession.fire(gdbtargetDebugSession); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onDidChangeActiveDebugSession.fire(gdbtargetDebugSession); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onConnected.fire(gdbtargetDebugSession); + await waitForMs(0); + + expect(cpuStates.activeCpuStates?.enableCpuStatesFlag).toEqual(false); + }); + + it('toolbar button state switches when changing the active debug session', async () => { + const executeCommandSpy = jest.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + const debugConfig2 = gdbTargetConfiguration({ name: 'Debug2' }); + const debugSession2 = debugSessionFactory(debugConfig2, '{session-id-2}'); + const gdbtargetDebugSession2 = new GDBTargetDebugSession(debugSession2); + cpuStates.activate(tracker); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onWillStartSession.fire(gdbtargetDebugSession); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onWillStartSession.fire(gdbtargetDebugSession2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cpuStates as any).sessionCpuStates.get(gdbtargetDebugSession2.session.id)!.enableCpuStatesFlag = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onDidChangeActiveDebugSession.fire(gdbtargetDebugSession); + expect(executeCommandSpy).toHaveBeenCalledWith('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', true); + executeCommandSpy.mockClear(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onDidChangeActiveDebugSession.fire(gdbtargetDebugSession2); + expect(executeCommandSpy).toHaveBeenCalledWith('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', false); + executeCommandSpy.mockClear(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onDidChangeActiveDebugSession.fire(undefined); + expect(executeCommandSpy).toHaveBeenCalledWith('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', false); + }); + + it('re-enables sessions and updates the toolbar context', async () => { + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + update: jest.fn().mockResolvedValue(undefined), + inspect: jest.fn().mockReturnValue({ globalValue: {}, workspaceValue: {} }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const executeCommandSpy = jest.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + cpuStates.activate(tracker); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tracker as any)._onWillStartSession.fire(gdbtargetDebugSession); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cpuStates as any).sessionCpuStates.get(gdbtargetDebugSession.session.id)!.enableCpuStatesFlag = false; + await cpuStates.resetViewState(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((cpuStates as any).sessionCpuStates.get(gdbtargetDebugSession.session.id)!.enableCpuStatesFlag).toBe(true); + expect(executeCommandSpy).toHaveBeenCalledWith('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', true); + }); + + }); }); diff --git a/src/features/cpu-states/cpu-states.ts b/src/features/cpu-states/cpu-states.ts index 6002eb26..05cc5f40 100644 --- a/src/features/cpu-states/cpu-states.ts +++ b/src/features/cpu-states/cpu-states.ts @@ -27,6 +27,7 @@ import { CpuStatesHistory } from './cpu-states-history'; import { calculateTime, extractPname } from '../../utils'; import { GDBTargetConfiguration } from '../../debug-configuration'; import { logger } from '../../logger'; +import { readCpuStatesEnabled, writeCpuStatesEnabled } from '../../views/dynamic-view-states'; // Architecturally defined registers (M-profile) const DWT_CTRL_ADDRESS = 0xE0001000; @@ -43,6 +44,8 @@ interface SessionCpuStates { isRunning: boolean; hasStates: boolean|undefined; skipFrequencyUpdate: boolean; + enableCpuStatesFlag: boolean; + configStateKey: string; } export class CpuStates { @@ -52,7 +55,6 @@ export class CpuStates { public activeSession: GDBTargetDebugSession | undefined; private sessionCpuStates: Map = new Map(); - private enableCpuStatesFlag: boolean = true; public get activeCpuStates(): SessionCpuStates|undefined { if (!this.activeSession) { @@ -70,7 +72,7 @@ export class CpuStates { tracker.onContinued(event => this.handleContinuedEvent(event)); tracker.onStopped(event => this.handleStoppedEvent(event)); tracker.onStackTrace(stackTrace => this.handleStackTrace(stackTrace)); - vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', true); + void vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', true); } protected handleOnWillStartSession(session: GDBTargetDebugSession): void { @@ -80,7 +82,9 @@ export class CpuStates { statesHistory: new CpuStatesHistory(extractPname(session.session.name)), isRunning: true, hasStates: undefined, - skipFrequencyUpdate: false + skipFrequencyUpdate: false, + enableCpuStatesFlag: true, + configStateKey: session.session.configuration.name }; this.sessionCpuStates.set(session.session.id, states); session.refreshTimer.onRefresh(async (refreshSession) => this.handlePeriodicRefresh(refreshSession)); @@ -95,6 +99,9 @@ export class CpuStates { protected handleActiveSessionChanged(session?: GDBTargetDebugSession): void { this.activeSession = session; + // Restore enabled/disabled state of CPU Time commands based on persisted settings + const enabled = session ? (this.sessionCpuStates.get(session.session.id)?.enableCpuStatesFlag ?? true) : false; + void vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', enabled); this._onRefresh.fire(0); } @@ -103,6 +110,13 @@ export class CpuStates { if (!cpuStates) { return; } + // Refine the key with the target-type prefix and restore the enabled/disabled state from settings.json + const configStateKey = await session.getConfigStateKey(); + cpuStates.configStateKey = configStateKey; + cpuStates.enableCpuStatesFlag = readCpuStatesEnabled(configStateKey) ?? true; + if (this.activeSession?.session.id === session.session.id) { + void vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', cpuStates.enableCpuStatesFlag); + } // Following call might fail if target not stopped on connect, returns undefined // Retry on first Stopped Event. cpuStates.hasStates = await this.supportsCpuStates(session); @@ -199,7 +213,7 @@ export class CpuStates { if (!states) { return; } - if (!this.enableCpuStatesFlag) { + if (!states.enableCpuStatesFlag) { return; } const newCycles = await session.readMemoryU32(DWT_CYCCNT_ADDRESS); @@ -326,14 +340,36 @@ export class CpuStates { this._onRefresh.fire(0); } + public get isEnabled(): boolean { + return this.activeCpuStates?.enableCpuStatesFlag ?? true; + } + public async enableCpuStates(): Promise { - this.enableCpuStatesFlag = true; - await vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', this.enableCpuStatesFlag); + const cpuStates = this.activeCpuStates; + if (!cpuStates) { + return; + } + cpuStates.enableCpuStatesFlag = true; + await writeCpuStatesEnabled(cpuStates.configStateKey, cpuStates.enableCpuStatesFlag); + await vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', cpuStates.enableCpuStatesFlag); } public async disableCpuStates(): Promise { - this.enableCpuStatesFlag = false; - await vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', this.enableCpuStatesFlag); + const cpuStates = this.activeCpuStates; + if (!cpuStates) { + return; + } + cpuStates.enableCpuStatesFlag = false; + await writeCpuStatesEnabled(cpuStates.configStateKey, cpuStates.enableCpuStatesFlag); + await vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', cpuStates.enableCpuStatesFlag); } + public async resetViewState(): Promise { + // Re-enable all active sessions in memory. + for (const states of this.sessionCpuStates.values()) { + states.enableCpuStatesFlag = true; + } + void vscode.commands.executeCommand('setContext', 'vscode-cmsis-debugger.cpuTimerEnabled', true); + logger.info('CPU States: CPU Timer reset'); + } }; diff --git a/src/views/component-viewer/component-viewer-base.ts b/src/views/component-viewer/component-viewer-base.ts index 09ef4b85..6a0bbaa5 100755 --- a/src/views/component-viewer/component-viewer-base.ts +++ b/src/views/component-viewer/component-viewer-base.ts @@ -25,6 +25,7 @@ import { perf, parsePerf } from './stats-config'; import { vscodeViewExists } from '../../vscode-utils'; import { EXTENSION_NAME, VIEW_PREFIX } from '../../manifest'; import { ExtendedGDBTargetConfiguration } from '../../debug-configuration/gdbtarget-configuration'; +import { readComponentViewerState, writeComponentViewerState } from '../dynamic-view-states'; export interface ScvdCollector { getScvdFilePaths(session: GDBTargetDebugSession): Promise; @@ -52,6 +53,8 @@ export class ComponentViewerBase { private _runningUpdate: boolean = false; private _refreshTimerEnabled: boolean = true; private _activeInputBox: vscode.InputBox | undefined; + private _filterDebounceTimer: NodeJS.Timeout | undefined; + private static readonly filterDebounceMs = 1000; private static readonly pendingUpdateDelayMs = 150; public constructor( @@ -100,10 +103,14 @@ export class ComponentViewerBase { }); const enablePeriodicUpdateCommandDisposable = vscode.commands.registerCommand(`${commandPrefix}.enablePeriodicUpdate`, async () => { this._refreshTimerEnabled = true; + await this.saveCurrentState(); + await vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, true); componentViewerLogger.info(`${this._viewName}: Auto refresh enabled`); }); const disablePeriodicUpdateCommandDisposable = vscode.commands.registerCommand(`${commandPrefix}.disablePeriodicUpdate`, async () => { this._refreshTimerEnabled = false; + await this.saveCurrentState(); + await vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, false); componentViewerLogger.info(`${this._viewName}: Auto refresh disabled`); }); const expandAllCommandDisposable = vscode.commands.registerCommand(`${commandPrefix}.expandAll`, async () => { @@ -128,6 +135,7 @@ export class ComponentViewerBase { filterTreeCommandDisposable, clearFilterCommandDisposable ); + vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, true); return true; } @@ -223,6 +231,14 @@ export class ComponentViewerBase { this._componentViewerTreeDataProvider.setFilter(value); void vscode.commands.executeCommand('setContext', `${this._viewId}.filterActive`, true); } + // Reset the timer so the view state is saved only after the user stops typing for filterDebounceMs. + if (this._filterDebounceTimer) { + clearTimeout(this._filterDebounceTimer); + } + this._filterDebounceTimer = setTimeout(() => { + this._filterDebounceTimer = undefined; + void this.saveCurrentState(); + }, ComponentViewerBase.filterDebounceMs); }; inputBox.onDidChangeValue(value => { @@ -253,6 +269,12 @@ export class ComponentViewerBase { if (this._activeInputBox) { this._activeInputBox.hide(); } + // Cancel any pending debounced save and persist the cleared state immediately. + if (this._filterDebounceTimer) { + clearTimeout(this._filterDebounceTimer); + this._filterDebounceTimer = undefined; + } + void this.saveCurrentState(); } protected async readScvdFiles(tracker: GDBTargetDebugTracker, session?: GDBTargetDebugSession): Promise { @@ -388,8 +410,10 @@ export class ComponentViewerBase { } private async handleOnConnected(session: GDBTargetDebugSession, tracker: GDBTargetDebugTracker): Promise { - // Update debug session - this._activeSession = session; + if (!this._activeSession) { + // Update debug session during launch connection but not during attach + this._activeSession = session; + } // Load SCVD files from cbuild-run await this.loadScvdFiles(session, tracker); } @@ -408,6 +432,9 @@ export class ComponentViewerBase { private async handleOnDidChangeActiveDebugSession(session: GDBTargetDebugSession | undefined): Promise { // Update debug session this._activeSession = session; + if (session) { + await this.restorePeriodicUpdateAndFilter(session); + } } private schedulePendingUpdate(updateReason: UpdateReason): void { @@ -500,4 +527,55 @@ export class ComponentViewerBase { perf?.logSummaries(); this._componentViewerTreeDataProvider.setRoots(roots); } + + private async saveCurrentState(): Promise { + if (!this._activeSession) { + return; + } + const configStateKey = await this._activeSession.getConfigStateKey(); + const filterPattern = this._componentViewerTreeDataProvider.filterPattern; + await writeComponentViewerState(this._viewId, configStateKey, this._refreshTimerEnabled, filterPattern); + } + + private async restorePeriodicUpdateAndFilter(session: GDBTargetDebugSession): Promise { + // Always reset to defaults before applying saved state to prevent state leaking while switching sessions + this._refreshTimerEnabled = true; + vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, true); + this._componentViewerTreeDataProvider.setFilter(undefined); + vscode.commands.executeCommand('setContext', `${this._viewId}.filterActive`, false); + + const state = readComponentViewerState(this._viewId, await session.getConfigStateKey()); + if (!state) { + return; + } + if (state.periodicUpdateEnabled !== undefined) { + this._refreshTimerEnabled = state.periodicUpdateEnabled; + vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, state.periodicUpdateEnabled); + componentViewerLogger.info(`${this._viewName}: Restored periodicUpdateEnabled=${state.periodicUpdateEnabled}`); + } + if (state.filterPattern !== undefined) { + this._componentViewerTreeDataProvider.setFilter(state.filterPattern); + vscode.commands.executeCommand('setContext', `${this._viewId}.filterActive`, true); + componentViewerLogger.info(`${this._viewName}: Restored filterPattern='${state.filterPattern}'`); + } + } + + public async resetViewState(): Promise { + // Reset in-memory state to defaults. + this._refreshTimerEnabled = true; + vscode.commands.executeCommand('setContext', `${this._viewId}.periodicUpdateEnabled`, true); + this._componentViewerTreeDataProvider.setFilter(undefined); + vscode.commands.executeCommand('setContext', `${this._viewId}.filterActive`, false); + // Unlock all locked instances + for (const wrapper of this._instances) { + wrapper.lockState = false; + const guiTree = wrapper.componentViewerInstance.getGuiTree(); + if (guiTree?.length) { + const rootNode: ScvdGuiInterface = guiTree[0]; + rootNode.isLocked = false; + } + } + this.schedulePendingUpdate('sessionChanged'); + componentViewerLogger.info(`${this._viewName}: View state reset`); + } } diff --git a/src/views/component-viewer/test/unit/component-viewer-base.test.ts b/src/views/component-viewer/test/unit/component-viewer-base.test.ts index 7b4b074a..be8777ee 100644 --- a/src/views/component-viewer/test/unit/component-viewer-base.test.ts +++ b/src/views/component-viewer/test/unit/component-viewer-base.test.ts @@ -30,6 +30,7 @@ import { ComponentViewerBase } from '../../component-viewer-base'; import { ComponentViewerTreeDataProvider } from '../../component-viewer-tree-view'; import type { ScvdGuiInterface } from '../../model/scvd-gui-interface'; import { debugSessionFactory, trackerFactory, OnRefreshCallback, Session, TrackerCallbacks } from '../../../../debug-session/__test__/debug-session.factory'; +import { readComponentViewerState } from '../../../dynamic-view-states'; const instanceFactory = jest.fn(() => ({ @@ -59,6 +60,11 @@ jest.mock('../../../../logger', () => ({ }, })); +jest.mock('../../../dynamic-view-states', () => ({ + readComponentViewerState: jest.fn(), + writeComponentViewerState: jest.fn().mockResolvedValue(undefined), +})); + function asMockedFunction( fn: (...args: Args) => Return ): jest.MockedFunction<(...args: Args) => Return> { @@ -122,6 +128,7 @@ describe('ComponentViewerBase', () => { provider = treeDataProviderFactory(); tracker = trackerFactory(); controller = createController(context, provider); + asMockedFunction(readComponentViewerState).mockReturnValue(undefined); // Extend registered commands for test class. const defaultMockedCommands = await vscode.commands.getCommands(); asMockedFunction(vscode.commands.getCommands).mockResolvedValue([ @@ -183,6 +190,7 @@ describe('ComponentViewerBase', () => { const sessionNoReader: Session = { session: { id: 's1' }, getCbuildRun: async () => undefined, + getConfigStateKey: async () => 's1', getPname: async () => undefined, refreshTimer: { onRefresh: jest.fn() }, }; @@ -303,8 +311,9 @@ describe('ComponentViewerBase', () => { await controller.activate(tracker as unknown as GDBTargetDebugTracker); const session: Session = { - session: { id: 's1' }, + session: { id: 's1', configuration: { name: 's1' } }, getCbuildRun: async () => undefined, + getConfigStateKey: async () => 's1', getPname: async () => undefined, refreshTimer: { onRefresh: jest.fn() }, }; @@ -1004,4 +1013,68 @@ describe('ComponentViewerBase', () => { await expect(handleExpandAll()).resolves.toBeUndefined(); expect(provider.expandAllElements).not.toHaveBeenCalled(); }); + + describe('view state save and restore', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('restorePeriodicUpdateAndFilter resets to defaults before applying saved state (prevents session state leaking)', async () => { + (controller as unknown as { _refreshTimerEnabled: boolean })._refreshTimerEnabled = false; + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + inspect: jest.fn().mockReturnValue({ globalValue: {}, workspaceValue: {} }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const session = debugSessionFactory('s1'); + const restoreState = (controller as unknown as { + restorePeriodicUpdateAndFilter: (s: Session) => Promise; + }).restorePeriodicUpdateAndFilter.bind(controller); + await restoreState(session); + + expect((controller as unknown as { _refreshTimerEnabled: boolean })._refreshTimerEnabled).toBe(true); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.periodicUpdateEnabled', true); + expect(provider.setFilter).toHaveBeenCalledWith(undefined); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.filterActive', false); + }); + + it('restorePeriodicUpdateAndFilter restores periodicUpdateEnabled and filter from workspace settings', async () => { + asMockedFunction(readComponentViewerState).mockReturnValue({ + periodicUpdateEnabled: false, + filterPattern: 'word', + }); + asMockedFunction(readComponentViewerState).mockReturnValue({ + periodicUpdateEnabled: false, + filterPattern: 'word', + }); + const session = debugSessionFactory('s1'); + const restoreState = (controller as unknown as { + restorePeriodicUpdateAndFilter: (s: Session) => Promise; + }).restorePeriodicUpdateAndFilter.bind(controller); + await restoreState(session); + + expect(readComponentViewerState).toHaveBeenCalledWith('testClass', 's1'); + expect(readComponentViewerState).toHaveBeenCalledWith('testClass', 's1'); + expect((controller as unknown as { _refreshTimerEnabled: boolean })._refreshTimerEnabled).toBe(false); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.periodicUpdateEnabled', false); + expect(provider.setFilter).toHaveBeenCalledWith('word'); + expect(provider.setFilter).toHaveBeenCalledWith('word'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.filterActive', true); + }); + + it('resetViewState resets runtime view state', async () => { + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + update: jest.fn().mockResolvedValue(undefined), + inspect: jest.fn().mockReturnValue({ globalValue: {}, workspaceValue: {} }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + (controller as unknown as { _refreshTimerEnabled: boolean })._refreshTimerEnabled = false; + await controller.resetViewState(); + + expect((controller as unknown as { _refreshTimerEnabled: boolean })._refreshTimerEnabled).toBe(true); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.periodicUpdateEnabled', true); + expect(provider.setFilter).toHaveBeenCalledWith(undefined); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'testClass.filterActive', false); + }); + }); }); diff --git a/src/views/dynamic-view-states.test.ts b/src/views/dynamic-view-states.test.ts new file mode 100644 index 00000000..5e3a6a96 --- /dev/null +++ b/src/views/dynamic-view-states.test.ts @@ -0,0 +1,268 @@ +/** + * 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 + * + * https://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 * as vscode from 'vscode'; +import { + clearAllViewState, + readComponentViewerState, + readCpuStatesEnabled, + writeComponentViewerState, + writeCpuStatesEnabled, +} from './dynamic-view-states'; + +const CONFIG_KEY = 'My-Target::Debug'; + +function mockGetConfiguration(globalValue: Record = {}, workspaceValue: Record = {}): jest.Mock { + const updateMock = jest.fn().mockResolvedValue(undefined); + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + update: updateMock, + inspect: jest.fn().mockReturnValue({ globalValue, workspaceValue }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + return updateMock; +} + +describe('dynamic-view-states', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Component Viewer state', () => { + it('returns user-level state when workspace is empty', () => { + mockGetConfiguration({ + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + }); + expect(readComponentViewerState('componentViewer', CONFIG_KEY)).toEqual({ + periodicUpdateEnabled: false, + }); + }); + + it('merges user and workspace state when reading', () => { + mockGetConfiguration( + { + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: false, + filterPattern: 'user-filter', + }, + }, + }, + { + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: true, + }, + }, + } + ); + expect(readComponentViewerState('componentViewer', CONFIG_KEY)).toEqual({ + periodicUpdateEnabled: true, + filterPattern: 'user-filter', + }); + }); + + it('writes disabled periodic update state to workspace settings', async () => { + const updateMock = mockGetConfiguration(); + await writeComponentViewerState('componentViewer', CONFIG_KEY, false, undefined); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + { + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + }, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('removes workspace state when periodic update is enabled', async () => { + const updateMock = mockGetConfiguration(); + await writeComponentViewerState('componentViewer', CONFIG_KEY, true, undefined); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + undefined, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('writes explicit enabled state when user setting disables periodic update', async () => { + const updateMock = mockGetConfiguration({ + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + }); + await writeComponentViewerState('componentViewer', CONFIG_KEY, true, undefined); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + { + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: true, + }, + }, + }, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('does not write explicit enabled state when only workspace disables periodic update', async () => { + const updateMock = mockGetConfiguration( + {}, + { + [CONFIG_KEY]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + } + ); + await writeComponentViewerState('componentViewer', CONFIG_KEY, true, undefined); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + undefined, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('preserves other workspace entries when writing state', async () => { + const otherConfigKey = 'Other-Target::Debug'; + const updateMock = mockGetConfiguration( + {}, + { + [otherConfigKey]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + } + ); + await writeComponentViewerState('componentViewer', CONFIG_KEY, true, 'user-filter'); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + { + [otherConfigKey]: { + componentViewer: { + periodicUpdateEnabled: false, + }, + }, + [CONFIG_KEY]: { + componentViewer: { + filterPattern: 'user-filter', + }, + }, + }, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('ignores unsupported component viewer ids', async () => { + const updateMock = mockGetConfiguration(); + expect(readComponentViewerState('wrongId', CONFIG_KEY)).toBeUndefined(); + + await writeComponentViewerState('wrongId', CONFIG_KEY, false, undefined); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it('clears both workspace and global levels', async () => { + const updateMock = jest.fn().mockResolvedValue(undefined); + jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + update: updateMock, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + await clearAllViewState(); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + undefined, + vscode.ConfigurationTarget.Workspace + ); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + undefined, + vscode.ConfigurationTarget.Global + ); + }); + }); + + describe('CPU States settings', () => { + it('returns user-level state when workspace is empty', () => { + mockGetConfiguration({ + [CONFIG_KEY]: { + cpuStates: false, + }, + }); + expect(readCpuStatesEnabled(CONFIG_KEY)).toBe(false); + }); + + it('uses workspace value before user value when reading', () => { + mockGetConfiguration( + { + [CONFIG_KEY]: { + cpuStates: false, + }, + }, + { + [CONFIG_KEY]: { + cpuStates: true, + }, + } + ); + expect(readCpuStatesEnabled(CONFIG_KEY)).toBe(true); + }); + + it('writes explicit enabled value when user setting disables CPU states', async () => { + const updateMock = mockGetConfiguration({ + [CONFIG_KEY]: { + cpuStates: false, + }, + }); + await writeCpuStatesEnabled(CONFIG_KEY, true); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + { + [CONFIG_KEY]: { + cpuStates: true, + }, + }, + vscode.ConfigurationTarget.Workspace + ); + }); + + it('does not write explicit enabled value when only workspace disables CPU states', async () => { + const updateMock = mockGetConfiguration( + {}, + { + [CONFIG_KEY]: { + cpuStates: false, + }, + } + ); + await writeCpuStatesEnabled(CONFIG_KEY, true); + expect(updateMock).toHaveBeenCalledWith( + 'vscode-cmsis-debugger.viewState', + undefined, + vscode.ConfigurationTarget.Workspace + ); + }); + }); +}); diff --git a/src/views/dynamic-view-states.ts b/src/views/dynamic-view-states.ts new file mode 100644 index 00000000..93cbaedd --- /dev/null +++ b/src/views/dynamic-view-states.ts @@ -0,0 +1,118 @@ +/** + * 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 + * + * https://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 * as vscode from 'vscode'; + +const SETTINGS_KEY = 'vscode-cmsis-debugger.viewState'; + +type ViewStateEntry = { + componentViewer: ComponentViewerState; + corePeripherals: ComponentViewerState; + cpuStates: boolean; +}; +type ViewStateByConfigKey = Record>; + +/* eslint-disable security/detect-object-injection */ +function readDynamicViewState(configStateKey: string, dynamicView: T, mode: 'global' | 'merged' = 'merged'): ViewStateEntry[T] | undefined { + const inspection = vscode.workspace.getConfiguration().inspect(SETTINGS_KEY); + const globalEntry = inspection?.globalValue?.[configStateKey]; + const workspaceEntry = inspection?.workspaceValue?.[configStateKey]; + const globalViewState = globalEntry?.[dynamicView] as ViewStateEntry[T] | undefined; + const workspaceViewState = workspaceEntry?.[dynamicView] as ViewStateEntry[T] | undefined; + + if (mode === 'global') return globalViewState; + // 'User' state provides defaults; 'Workspace' state overrides only the properties it defines. + if (typeof globalViewState === 'object' && globalViewState !== null && typeof workspaceViewState === 'object' && workspaceViewState !== null) { + return { ...globalViewState, ...workspaceViewState } as ViewStateEntry[T]; + } + return workspaceViewState ?? globalViewState; +} + +async function writeWorkspaceDynamicViewState(configStateKey: string, dynamicView: T, state: ViewStateEntry[T] | undefined): Promise { + const inspection = vscode.workspace.getConfiguration().inspect(SETTINGS_KEY); + const entriesToStore: ViewStateByConfigKey = { ...(inspection?.workspaceValue ?? {}) }; + const entryToStore: Partial = { ...(entriesToStore[configStateKey] ?? {}) }; + if (state === undefined) { + // Undefined means this dynamic view has no workspace override, so remove it from the stored entry. + delete entryToStore[dynamicView]; + } else { + entryToStore[dynamicView] = state; + } + if (Object.keys(entryToStore).length === 0) { + // Drop empty debug configuration entries instead of leaving empty objects in settings.json. + delete entriesToStore[configStateKey]; + } else { + entriesToStore[configStateKey] = entryToStore; + } + // VS Code removes the entire setting when the updated value is undefined. + const valueToStore = Object.keys(entriesToStore).length === 0 ? undefined : entriesToStore; + await vscode.workspace.getConfiguration().update(SETTINGS_KEY, valueToStore, vscode.ConfigurationTarget.Workspace); +} +/* eslint-enable security/detect-object-injection */ + +export async function clearAllViewState(): Promise { + await Promise.all([ + vscode.workspace.getConfiguration().update(SETTINGS_KEY, undefined, vscode.ConfigurationTarget.Workspace), + vscode.workspace.getConfiguration().update(SETTINGS_KEY, undefined, vscode.ConfigurationTarget.Global), + ]); +} + +// ------------------------------------------------------------------------------------------------- +// Component Viewer and Core Peripherals +// ------------------------------------------------------------------------------------------------- + +interface ComponentViewerState { + periodicUpdateEnabled?: boolean; + filterPattern?: string; +} + +export function readComponentViewerState(viewId: string, configStateKey: string): ComponentViewerState | undefined { + if (viewId !== 'componentViewer' && viewId !== 'corePeripherals') { + return undefined; + } + return readDynamicViewState(configStateKey, viewId, 'merged'); +} + +export async function writeComponentViewerState(viewId: string, configStateKey: string, refreshTimerEnabled: boolean, filterPattern: string | undefined): Promise { + if (viewId !== 'componentViewer' && viewId !== 'corePeripherals') { + return; + } + const userState = readDynamicViewState(configStateKey, viewId, 'global'); + // If 'User' settings disable periodicUpdate but this 'Workspace' enables it, + // write true explicitly so the 'User' value does not bleed through. + const needsExplicitPeriodicUpdate = refreshTimerEnabled && userState?.periodicUpdateEnabled === false; + const state: ComponentViewerState = { + ...(!refreshTimerEnabled || needsExplicitPeriodicUpdate ? { periodicUpdateEnabled: refreshTimerEnabled } : {}), + ...(filterPattern !== undefined ? { filterPattern } : {}), + }; + await writeWorkspaceDynamicViewState(configStateKey, viewId, Object.keys(state).length === 0 ? undefined : state); +} + +// ------------------------------------------------------------------------------------------------- +// CPU States +// ------------------------------------------------------------------------------------------------- + +export function readCpuStatesEnabled(configStateKey: string): boolean | undefined { + return readDynamicViewState(configStateKey, 'cpuStates', 'merged'); +} + +export async function writeCpuStatesEnabled(configStateKey: string, enabled: boolean): Promise { + const userState = readDynamicViewState(configStateKey, 'cpuStates', 'global'); + // If 'User' settings disable cpuStates but this 'Workspace' enables it, + // write true explicitly so the 'User' value does not bleed through. + const stateToStore = enabled ? userState === false ? true : undefined : false; + await writeWorkspaceDynamicViewState(configStateKey, 'cpuStates', stateToStore); +}