diff --git a/src/debug-session/gdbtarget-debug-session.ts b/src/debug-session/gdbtarget-debug-session.ts old mode 100644 new mode 100755 diff --git a/src/debug-session/gdbtarget-debug-tracker.ts b/src/debug-session/gdbtarget-debug-tracker.ts old mode 100644 new mode 100755 diff --git a/src/views/live-watch/live-watch.test.ts b/src/views/live-watch/live-watch.test.ts index 5742d454..4c7d305a 100644 --- a/src/views/live-watch/live-watch.test.ts +++ b/src/views/live-watch/live-watch.test.ts @@ -97,27 +97,24 @@ describe('LiveWatchTreeDataProvider', () => { expect((liveWatchTreeDataProvider as any).activeSession).toBeUndefined(); }); - it('refreshes on stopped event and on onDidChangeActiveStackItem and onMemory event', async () => { - const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(''); + it('schedules a refresh on onDidChangeActiveStackItem, onMemory, and onInvalidated events', async () => { + const scheduleSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'schedulePendingRefresh'); await liveWatchTreeDataProvider.activate(tracker); // Activate session (tracker as any)._onDidChangeActiveDebugSession.fire(gdbtargetDebugSession); expect((liveWatchTreeDataProvider as any).activeSession?.session.id).toEqual(gdbtargetDebugSession.session.id); - // Fire stopped event - (tracker as any)._onStopped.fire({ session: gdbtargetDebugSession }); - expect(refreshSpy).toHaveBeenCalled(); - refreshSpy.mockClear(); + scheduleSpy.mockClear(); // Fire onDidChangeActiveStackItem event (tracker as any)._onDidChangeActiveStackItem.fire({ item: { frameId: 1 } }); - expect(refreshSpy).toHaveBeenCalled(); - refreshSpy.mockClear(); + expect(scheduleSpy).toHaveBeenCalled(); + scheduleSpy.mockClear(); // Fire onMemory event (tracker as any)._onMemory.fire({ session: gdbtargetDebugSession, event: { memoryReference: '0x1234', offset: 0, count: 4 } }); - expect(refreshSpy).toHaveBeenCalled(); - refreshSpy.mockClear(); + expect(scheduleSpy).toHaveBeenCalled(); + scheduleSpy.mockClear(); // Fire onInvalidated event (tracker as any)._onInvalidated.fire({ session: gdbtargetDebugSession, event: { memoryReference: '0x1234', offset: 0, count: 4 } }); - expect(refreshSpy).toHaveBeenCalled(); + expect(scheduleSpy).toHaveBeenCalled(); }); it('calls save function when extension is deactivating', async () => { @@ -127,6 +124,23 @@ describe('LiveWatchTreeDataProvider', () => { expect(saveSpy).toHaveBeenCalled(); }); + it('clears highlighted labels before saving on session stop', async () => { + await liveWatchTreeDataProvider.activate(tracker); + // Activate session and set up nodes with highlights + (tracker as any)._onDidChangeActiveDebugSession.fire(gdbtargetDebugSession); + const node = makeNode('myVar', { + result: 'value', + variablesReference: 0, + highlightedLabel: { label: 'myVar = ', highlights: [[0, 5]] } + }, 1); + (liveWatchTreeDataProvider as any).roots = [node]; + // Stop session triggers save + (tracker as any)._onWillStopSession.fire(gdbtargetDebugSession); + // Allow async handlers to complete + await new Promise(resolve => setTimeout(resolve, 0)); + expect(node.value.highlightedLabel).toBeUndefined(); + }); + it('reassigns IDs sequentially for restored nodes on construction', () => { const storedNodes = [ { id: 5, expression: 'expression1', value: 'some-value', parent: undefined }, @@ -202,7 +216,8 @@ describe('LiveWatchTreeDataProvider', () => { describe('node management', () => { it('add creates a new root node', async () => { - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: '1234', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateInitialExpression').mockResolvedValue({ result: '1234', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: '1234', variablesReference: 0 }); // adapt method name addToRoots (changed implementation) await (liveWatchTreeDataProvider as any).addToRoots('expression'); expect((liveWatchTreeDataProvider as any).roots.length).toBe(1); @@ -295,7 +310,8 @@ describe('LiveWatchTreeDataProvider', () => { }); it('AddFromSelection adds selected text as new live watch expression to roots', async () => { - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: '5678', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateInitialExpression').mockResolvedValue({ result: '5678', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: '5678', variablesReference: 0 }); // Mock the active text editor with fake range const fakeRange = { start: { line: 0, character: 0 }, end: { line: 0, character: 17 } }; const mockEditor: any = { @@ -343,7 +359,7 @@ describe('LiveWatchTreeDataProvider', () => { it('refresh updates all root node values', async () => { const node = makeNode('expression', { result: 'old-value', variablesReference: 1 }, 1); (liveWatchTreeDataProvider as any).roots = [node]; - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: 'new-value', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: 'new-value', variablesReference: 0 }); await (liveWatchTreeDataProvider as any).refresh(); expect(node.value.result).toBe('new-value'); }); @@ -351,7 +367,7 @@ describe('LiveWatchTreeDataProvider', () => { it('refresh(node) updates only that node', async () => { const node = makeNode('expression', { result: 'old-value', variablesReference: 1 }, 1); (liveWatchTreeDataProvider as any).roots = [node]; - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: 'new-value', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: 'new-value', variablesReference: 0 }); await (liveWatchTreeDataProvider as any).refresh(node); expect(node.value.result).toBe('new-value'); }); @@ -360,8 +376,8 @@ describe('LiveWatchTreeDataProvider', () => { const nodeA = makeNode('node-A', { result: 'value-A', variablesReference: 0 }, 1); const nodeB = makeNode('node-B', { result: 'value-B', variablesReference: 0 }, 2); (liveWatchTreeDataProvider as any).roots = [nodeA, nodeB]; - const evalMock = jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate') - .mockImplementation(async (expr: unknown) => ({ result: String(expr) + '-updated', variablesReference: 0 })); + const evalMock = jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression') + .mockImplementation(async (node: any) => ({ result: String(node.expression) + '-updated', variablesReference: 0 })); const fireSpy = jest.spyOn((liveWatchTreeDataProvider as any)._onDidChangeTreeData, 'fire'); await (liveWatchTreeDataProvider as any).refresh(); expect(evalMock).toHaveBeenCalledTimes(2); @@ -371,6 +387,134 @@ describe('LiveWatchTreeDataProvider', () => { // fire called with undefined (no specific node) per implementation expect(fireSpy.mock.calls[0][0]).toBeUndefined(); }); + + it('refresh highlights nodes whose value changed', async () => { + const node = makeNode('myVar', { result: 'old-value', variablesReference: 0 }, 1); + (liveWatchTreeDataProvider as any).roots = [node]; + (liveWatchTreeDataProvider as any)._activeSession = { + evaluateGlobalExpression: jest.fn().mockResolvedValue({ result: 'new-value', variablesReference: 0 }), + session: {} + }; + await (liveWatchTreeDataProvider as any).refresh(); + expect(node.value.highlightedLabel).toEqual({ + label: 'myVar = ', + highlights: [[0, 5]] + }); + }); + + it('refresh does not highlight nodes whose value is unchanged', async () => { + const node = makeNode('myVar', { result: 'same-value', variablesReference: 0 }, 1); + (liveWatchTreeDataProvider as any).roots = [node]; + (liveWatchTreeDataProvider as any)._activeSession = { + evaluateGlobalExpression: jest.fn().mockResolvedValue({ result: 'same-value', variablesReference: 0 }), + session: {} + }; + await (liveWatchTreeDataProvider as any).refresh(); + expect(node.value.highlightedLabel).toBeUndefined(); + }); + }); + + describe('schedulePendingRefresh', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('sets _pendingRefresh to true', () => { + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + expect((liveWatchTreeDataProvider as any)._pendingRefresh).toBe(true); + }); + + it('triggers a refresh after the 50ms debounce', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + expect(refreshSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(50); + await Promise.resolve(); // flush setTimeout callback + await Promise.resolve(); // flush runPendingRefresh internals + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('debounces multiple rapid calls, triggering only one refresh', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + jest.advanceTimersByTime(50); + await Promise.resolve(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('clears the existing timer before scheduling a new one', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + (liveWatchTreeDataProvider as any)._pendingUpdateTimer = setTimeout(() => { /* placeholder */ }, 100); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('does not trigger a refresh before 50ms have elapsed', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any).schedulePendingRefresh(); + jest.advanceTimersByTime(49); + await Promise.resolve(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + }); + + describe('runPendingRefresh', () => { + it('does nothing if _updateInProgress is true', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any)._updateInProgress = true; + (liveWatchTreeDataProvider as any)._pendingRefresh = true; + await (liveWatchTreeDataProvider as any).runPendingRefresh(); + expect(refreshSpy).not.toHaveBeenCalled(); + // _updateInProgress remains true since we returned early without modifying it + expect((liveWatchTreeDataProvider as any)._updateInProgress).toBe(true); + }); + + it('calls refresh once when there is a single pending refresh', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any)._pendingRefresh = true; + await (liveWatchTreeDataProvider as any).runPendingRefresh(); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect((liveWatchTreeDataProvider as any)._pendingRefresh).toBe(false); + expect((liveWatchTreeDataProvider as any)._updateInProgress).toBe(false); + }); + + it('loops to handle a pending refresh set again during refresh execution', async () => { + let callCount = 0; + jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockImplementation(async () => { + callCount++; + if (callCount === 1) { + // Simulate another refresh being scheduled while one is in progress + (liveWatchTreeDataProvider as any)._pendingRefresh = true; + } + }); + (liveWatchTreeDataProvider as any)._pendingRefresh = true; + await (liveWatchTreeDataProvider as any).runPendingRefresh(); + expect(callCount).toBe(2); + expect((liveWatchTreeDataProvider as any)._pendingRefresh).toBe(false); + expect((liveWatchTreeDataProvider as any)._updateInProgress).toBe(false); + }); + + it('resets _updateInProgress to false after completion', async () => { + jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any)._pendingRefresh = true; + await (liveWatchTreeDataProvider as any).runPendingRefresh(); + expect((liveWatchTreeDataProvider as any)._updateInProgress).toBe(false); + }); + + it('does not call refresh when _pendingRefresh is false', async () => { + const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); + (liveWatchTreeDataProvider as any)._pendingRefresh = false; + await (liveWatchTreeDataProvider as any).runPendingRefresh(); + expect(refreshSpy).not.toHaveBeenCalled(); + expect((liveWatchTreeDataProvider as any)._updateInProgress).toBe(false); + }); }); describe('command registration', () => { @@ -407,7 +551,14 @@ describe('LiveWatchTreeDataProvider', () => { it('add command adds a node when expression provided', async () => { (vscode.window as any).showInputBox = jest.fn().mockResolvedValue('expression'); - const evaluateSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue('someValue'); + const evaluatedValue = { + result: 'someValue', + variablesReference: 0 + } as LiveWatchValue; + const evaluateSpy = jest + .spyOn(liveWatchTreeDataProvider as any, 'evaluateInitialExpression') + .mockResolvedValue(evaluatedValue); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue(evaluatedValue); await liveWatchTreeDataProvider.activate(tracker); const handler = getRegisteredHandler('vscode-cmsis-debugger.liveWatch.add'); expect(handler).toBeDefined(); @@ -480,7 +631,8 @@ describe('LiveWatchTreeDataProvider', () => { }); it('watch window command adds variable name root', async () => { - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: 'value', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateInitialExpression').mockResolvedValue({ result: 'value', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: 'value', variablesReference: 0 }); await liveWatchTreeDataProvider.activate(tracker); const handler = getRegisteredHandler('vscode-cmsis-debugger.liveWatch.addToLiveWatchFromWatchWindow'); expect(handler).toBeDefined(); @@ -498,7 +650,8 @@ describe('LiveWatchTreeDataProvider', () => { }); it('variables view command adds variable name root', async () => { - jest.spyOn(liveWatchTreeDataProvider as any, 'evaluate').mockResolvedValue({ result: '12345', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateInitialExpression').mockResolvedValue({ result: '12345', variablesReference: 0 }); + jest.spyOn(liveWatchTreeDataProvider as any, 'evaluateNodeExpression').mockResolvedValue({ result: '12345', variablesReference: 0 }); await liveWatchTreeDataProvider.activate(tracker); const handler = getRegisteredHandler('vscode-cmsis-debugger.liveWatch.addToLiveWatchFromVariablesView'); expect(handler).toBeDefined(); @@ -612,6 +765,7 @@ describe('LiveWatchTreeDataProvider', () => { }); it('periodic refresh only refreshes when enabled for the active session', async () => { + jest.useFakeTimers(); const refreshSpy = jest.spyOn(liveWatchTreeDataProvider as any, 'refresh').mockResolvedValue(undefined); await liveWatchTreeDataProvider.activate(tracker); (tracker as any)._onWillStartSession.fire(gdbtargetDebugSession); @@ -620,13 +774,18 @@ describe('LiveWatchTreeDataProvider', () => { state.periodicUpdateEnabled = false; (gdbtargetDebugSession.refreshTimer as any)._onRefresh.fire(gdbtargetDebugSession); + jest.advanceTimersByTime(50); + await Promise.resolve(); await Promise.resolve(); expect(refreshSpy).not.toHaveBeenCalled(); state.periodicUpdateEnabled = true; (gdbtargetDebugSession.refreshTimer as any)._onRefresh.fire(gdbtargetDebugSession); + jest.advanceTimersByTime(50); + await Promise.resolve(); await Promise.resolve(); expect(refreshSpy).toHaveBeenCalled(); + jest.useRealTimers(); }); it('reset view state command resets Live Watch view state', async () => { @@ -786,9 +945,9 @@ describe('LiveWatchTreeDataProvider', () => { }); }); - describe('evaluate', () => { + describe('evaluateInitialExpression', () => { it('returns No active session when none set', async () => { - const result = await (liveWatchTreeDataProvider as any).evaluate('myExpression'); + const result = await (liveWatchTreeDataProvider as any).evaluateInitialExpression('myExpression'); expect(result.result).toBe('No active session'); expect(result.variablesReference).toBe(0); }); @@ -799,7 +958,7 @@ describe('LiveWatchTreeDataProvider', () => { evaluateGlobalExpression: jest.fn().mockResolvedValue('string-value'), session: {} }; - const evalResult = await (liveWatchTreeDataProvider as any).evaluate('myExpression'); + const evalResult = await (liveWatchTreeDataProvider as any).evaluateInitialExpression('myExpression'); expect(evalResult.result).toBe('string-value'); expect(evalResult.variablesReference).toBe(0); }); @@ -810,10 +969,26 @@ describe('LiveWatchTreeDataProvider', () => { evaluateGlobalExpression: jest.fn().mockResolvedValue(responseObj), session: {} }; - const evalResult = await (liveWatchTreeDataProvider as any).evaluate('myExpression'); + const evalResult = await (liveWatchTreeDataProvider as any).evaluateInitialExpression('myExpression'); expect(evalResult.result).toBe('value'); expect(evalResult.variablesReference).toBe(1234); }); }); + + describe('evaluateNodeExpression', () => { + it('highlights and returns node value when session returns a string error', async () => { + const node = makeNode('myVar', { result: 'prev', variablesReference: 0 }, 1); + (liveWatchTreeDataProvider as any)._activeSession = { + evaluateGlobalExpression: jest.fn().mockResolvedValue('error-message'), + session: {} + }; + const evalResult = await (liveWatchTreeDataProvider as any).evaluateNodeExpression(node); + expect(evalResult.result).toBe('error-message'); + expect(evalResult.highlightedLabel).toEqual({ + label: 'myVar = ', + highlights: [[0, 5]] + }); + }); + }); }); /* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/src/views/live-watch/live-watch.ts b/src/views/live-watch/live-watch.ts index da5c059b..4a0469bc 100644 --- a/src/views/live-watch/live-watch.ts +++ b/src/views/live-watch/live-watch.ts @@ -34,6 +34,7 @@ export interface LiveWatchValue { variablesReference: number; type?: string; evaluateName?: string; + highlightedLabel?: vscode.TreeItemLabel | undefined; } interface SessionLiveWatchState { @@ -52,6 +53,9 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider(); + private _pendingRefresh: boolean = false; + private _pendingUpdateTimer: NodeJS.Timeout | undefined; + private _updateInProgress: boolean = false; constructor(private readonly context: vscode.ExtensionContext) { this.roots = this.context.workspaceState.get(this.STORAGE_KEY) ?? []; @@ -90,7 +94,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { if ((item.item as vscode.DebugStackFrame).frameId !== undefined) { - await this.refresh(); + this.schedulePendingRefresh(); } }); - const onStackTrace = tracker.onStackTrace(async () => await this.refresh()); + const onStackTrace = tracker.onStackTrace(async () => this.schedulePendingRefresh()); // Clearing active session on closing the session const onWillStopSession = tracker.onWillStopSession(async (session) => { this.sessionLiveWatchStates.delete(session.session.id); @@ -123,7 +127,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { @@ -170,7 +174,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { @@ -181,7 +185,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { const state = this.sessionLiveWatchStates.get(refreshSession.session.id); if (this._activeSession?.session.id === refreshSession.session.id && state?.periodicUpdateEnabled) { - await this.refresh(); + this.schedulePendingRefresh(); } }); } @@ -191,7 +195,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { if (this._activeSession?.session.id != session.session.id) { @@ -212,7 +216,32 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { + if (this._pendingRefresh) { + await this.runPendingRefresh(); + this._pendingRefresh = false; + } + }, 50); + } + + private async runPendingRefresh() { + if (this._updateInProgress) { + return; + } + this._updateInProgress = true; + while(this._pendingRefresh) { + this._pendingRefresh = false; + await this.refresh(); + } + this._updateInProgress = false; } private async addVSCodeCommands(): Promise { @@ -376,7 +405,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { + private async evaluateInitialExpression(expression: string): Promise { const response: LiveWatchValue = { result: '', variablesReference: 0 }; if (!this._activeSession) { response.result = 'No active session'; @@ -393,6 +422,29 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider { + if (!this._activeSession) { + node.value.result = 'No active session'; + return node.value; + } + const result = await this._activeSession.evaluateGlobalExpression(node.expression, 'watch'); + if (typeof result == 'string') { + node.value.result = result; + node.value.highlightedLabel = { label: node.expression + ' = ', highlights: [[0, node.expression.length]] }; + return node.value; + } + // Highlight label if value has changed + if (node.value.result !== result.result) { + node.value.highlightedLabel = { label: node.expression + ' = ', highlights: [[0, node.expression.length]] }; + } else { + node.value.highlightedLabel = undefined; + } + node.value.result = result.result; + node.value.variablesReference = result.variablesReference; + node.value.type = result.type ?? ''; + return node.value; + } + private async handleSetValueCommand(node: LiveWatchNode) { if (!node) { return; @@ -430,7 +482,7 @@ export class LiveWatchTreeDataProvider implements vscode.TreeDataProvider