diff --git a/packages/debug/__tests__/browser/debug-session.test.ts b/packages/debug/__tests__/browser/debug-session.test.ts new file mode 100644 index 0000000000..d944437470 --- /dev/null +++ b/packages/debug/__tests__/browser/debug-session.test.ts @@ -0,0 +1,74 @@ +import { Disposable, Emitter } from '@opensumi/ide-core-common'; +import { DebugSession } from '@opensumi/ide-debug/lib/browser/debug-session'; + +import type { DebugSessionOptions } from '@opensumi/ide-debug/lib/common'; + +const createSession = () => { + const connection = { + disposed: false, + onRequest: jest.fn(), + on: jest.fn(() => Disposable.create(() => {})), + onDidCustomEvent: new Emitter().event, + dispose: jest.fn(), + }; + const breakpointManager = { + breakpointsEnabled: false, + onDidChangeBreakpoints: jest.fn(() => Disposable.create(() => {})), + onDidChangeExceptionsBreakpoints: jest.fn(() => Disposable.create(() => {})), + clearAllStatus: jest.fn(), + }; + const options: DebugSessionOptions = { + configuration: { + name: 'test', + type: 'node', + request: 'launch', + }, + index: 0, + }; + + return new DebugSession( + 'session-1', + options, + connection as any, + {} as any, + {} as any, + breakpointManager as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); +}; + +describe('DebugSession evaluate', () => { + it('fires variable change for repl evaluation', async () => { + const session = createSession(); + session.sendRequest = jest.fn().mockResolvedValue({ + body: { + result: '1', + }, + }); + const onVariableChange = jest.fn(); + + session.onVariableChange(onVariableChange); + await session.evaluate('t = 1', 'repl'); + + expect(onVariableChange).toHaveBeenCalledTimes(1); + }); + + it('does not fire variable change for watch evaluation', async () => { + const session = createSession(); + session.sendRequest = jest.fn().mockResolvedValue({ + body: { + result: '1', + }, + }); + const onVariableChange = jest.fn(); + + session.onVariableChange(onVariableChange); + await session.evaluate('t', 'watch'); + + expect(onVariableChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/debug/__tests__/browser/view/variables/debug-variables-tree.model.service.test.ts b/packages/debug/__tests__/browser/view/variables/debug-variables-tree.model.service.test.ts index 208df3628e..4cfe608e88 100644 --- a/packages/debug/__tests__/browser/view/variables/debug-variables-tree.model.service.test.ts +++ b/packages/debug/__tests__/browser/view/variables/debug-variables-tree.model.service.test.ts @@ -1,4 +1,4 @@ -import { IContextKeyService } from '@opensumi/ide-core-browser'; +import { Deferred, IContextKeyService } from '@opensumi/ide-core-browser'; import { AbstractContextMenuService, ICtxMenuRenderer } from '@opensumi/ide-core-browser/lib/menu/next'; import { Disposable } from '@opensumi/ide-core-common'; import { IDebugSessionManager } from '@opensumi/ide-debug'; @@ -15,6 +15,8 @@ import { DebugContextKey } from './../../../../src/browser/contextkeys/debug-con describe('Debug Variables Tree Model', () => { const mockInjector = createBrowserInjector([]); let debugVariablesModelService: DebugVariablesModelService; + let viewModelChangeListener: (() => void | Promise) | undefined; + let variableChangeListener: (() => void | Promise) | undefined; const mockDebugHoverSource = { onDidChange: jest.fn(() => Disposable.create(() => {})), } as any; @@ -54,7 +56,11 @@ describe('Debug Variables Tree Model', () => { } as any; const mockDebugViewModel = { - onDidChange: jest.fn(), + currentSession: undefined as any, + onDidChange: jest.fn((listener) => { + viewModelChangeListener = listener; + return Disposable.create(() => {}); + }), }; beforeAll(() => { @@ -113,6 +119,7 @@ describe('Debug Variables Tree Model', () => { expect(typeof debugVariablesModelService.dispose).toBe('function'); expect(typeof debugVariablesModelService.onDidUpdateTreeModel).toBe('function'); expect(typeof debugVariablesModelService.initTreeModel).toBe('function'); + expect(typeof debugVariablesModelService.refresh).toBe('function'); expect(typeof debugVariablesModelService.initDecorations).toBe('function'); expect(typeof debugVariablesModelService.activeNodeDecoration).toBe('function'); expect(typeof debugVariablesModelService.activeNodeActivedDecoration).toBe('function'); @@ -134,6 +141,344 @@ describe('Debug Variables Tree Model', () => { expect(mockDebugViewModel.onDidChange).toHaveBeenCalledTimes(1); }); + it('refreshes when current session variables change', async () => { + const mockSession = { + on: jest.fn(), + onVariableChange: jest.fn((listener) => { + variableChangeListener = listener; + return Disposable.create(() => {}); + }), + } as any; + const refreshSpy = jest.spyOn(debugVariablesModelService, 'refresh').mockResolvedValue(); + + mockDebugViewModel.currentSession = mockSession; + await viewModelChangeListener?.(); + await variableChangeListener?.(); + + expect(mockSession.onVariableChange).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledTimes(1); + refreshSpy.mockRestore(); + }); + + it('does not refresh a stale tree when session changes before the new tree is ready', async () => { + jest.useFakeTimers(); + try { + let currentVariableChangeListener: (() => void | Promise) | undefined; + const oldSession = { + id: 'old-session', + terminated: false, + onVariableChange: jest.fn(() => Disposable.create(() => {})), + } as any; + const newSession = { + id: 'new-session', + terminated: false, + onVariableChange: jest.fn((listener) => { + currentVariableChangeListener = listener; + return Disposable.create(() => {}); + }), + } as any; + const oldWatcher = { + callback: jest.fn(async () => {}), + }; + + (debugVariablesModelService as any)._activeTreeModel = { + root: { + session: oldSession, + path: '/oldRoot', + children: [], + watchEvents: new Map([['/oldRoot', oldWatcher]]), + }, + }; + (debugVariablesModelService as any).currentSession = oldSession; + mockDebugViewModel.currentSession = newSession; + (debugVariablesModelService as any).listenCurrentSessionVariableChange(); + + await currentVariableChangeListener?.(); + await jest.advanceTimersByTimeAsync(100); + + expect(oldWatcher.callback).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not refresh when the subscribed session is terminated', async () => { + jest.useFakeTimers(); + try { + let currentVariableChangeListener: (() => void | Promise) | undefined; + const session = { + id: 'terminated-session', + terminated: true, + onVariableChange: jest.fn((listener) => { + currentVariableChangeListener = listener; + return Disposable.create(() => {}); + }), + } as any; + const watcher = { + callback: jest.fn(async () => {}), + }; + + (debugVariablesModelService as any)._activeTreeModel = { + root: { + session, + path: '/terminatedRoot', + children: [], + watchEvents: new Map([['/terminatedRoot', watcher]]), + }, + }; + (debugVariablesModelService as any).currentSession = undefined; + mockDebugViewModel.currentSession = session; + (debugVariablesModelService as any).listenCurrentSessionVariableChange(); + + await currentVariableChangeListener?.(); + await jest.advanceTimersByTimeAsync(100); + + expect(watcher.callback).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('recreates tree listener collection after disposing old listeners', () => { + const previousCollection = (debugVariablesModelService as any).disposableCollection; + + debugVariablesModelService.initDecorations(mockRoot); + (debugVariablesModelService as any)._activeTreeModel = { + root: mockRoot, + }; + debugVariablesModelService.listenTreeViewChange(); + + expect((debugVariablesModelService as any).disposableCollection).not.toBe(previousCollection); + }); + + it('waits for queued watcher refresh before resolving refresh', async () => { + jest.useFakeTimers(); + try { + const watcherDeferred = new Deferred(); + const watcher = { + callback: jest.fn(() => watcherDeferred.promise), + }; + const root = { + path: '/testRoot', + children: [], + watchEvents: new Map([['/testRoot', watcher]]), + }; + const refreshed = jest.fn(); + let refreshResolved = false; + + (debugVariablesModelService as any)._activeTreeModel = { + root, + }; + debugVariablesModelService.onDidRefreshed(refreshed); + + const refreshPromise = debugVariablesModelService.refresh(root as any).then(() => { + refreshResolved = true; + }); + await Promise.resolve(); + + expect(refreshResolved).toBe(false); + + await jest.advanceTimersByTimeAsync(100); + expect(watcher.callback).toHaveBeenCalledTimes(1); + expect(refreshResolved).toBe(false); + + watcherDeferred.resolve(); + await refreshPromise; + + expect(refreshed).toHaveBeenCalledTimes(1); + expect(refreshResolved).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('cancels pending queued refresh work when disposed', async () => { + jest.useFakeTimers(); + try { + const watcher = { + callback: jest.fn(async () => {}), + }; + const root = { + path: '/disposeRoot', + children: [], + watchEvents: new Map([['/disposeRoot', watcher]]), + }; + const refreshed = jest.fn(); + + (debugVariablesModelService as any)._activeTreeModel = { + root, + }; + debugVariablesModelService.onDidRefreshed(refreshed); + + const refreshPromise = debugVariablesModelService.refresh(root as any); + await Promise.resolve(); + + debugVariablesModelService.dispose(); + + expect(debugVariablesModelService.flushEventQueuePromise).toBeFalsy(); + + await jest.advanceTimersByTimeAsync(100); + await refreshPromise; + + expect(watcher.callback).not.toHaveBeenCalled(); + expect(refreshed).not.toHaveBeenCalled(); + } finally { + (debugVariablesModelService as any)._disposed = false; + jest.useRealTimers(); + } + }); + + it('queues another flush when a refresh arrives during an active flush', async () => { + jest.useFakeTimers(); + try { + const flushDeferred = new Deferred(); + const fooWatcher = { + callback: jest.fn(() => flushDeferred.promise), + }; + const barWatcher = { + callback: jest.fn(async () => {}), + }; + + (debugVariablesModelService as any)._activeTreeModel = { + root: { + watchEvents: new Map([ + ['/testRoot/foo', fooWatcher], + ['/testRoot/bar', barWatcher], + ]), + }, + }; + + (debugVariablesModelService as any).queueChangeEvent('/testRoot/foo', jest.fn()); + await jest.advanceTimersByTimeAsync(100); + expect(fooWatcher.callback).toHaveBeenCalledTimes(1); + + (debugVariablesModelService as any).queueChangeEvent('/testRoot/bar', jest.fn()); + flushDeferred.resolve(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(100); + + expect(barWatcher.callback).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('preserves queued callbacks if flushEventQueue is called during an active flush', async () => { + jest.useFakeTimers(); + try { + const flushDeferred = new Deferred(); + const fooWatcher = { + callback: jest.fn(() => flushDeferred.promise), + }; + const barWatcher = { + callback: jest.fn(async () => {}), + }; + const barCallback = jest.fn(); + + (debugVariablesModelService as any)._activeTreeModel = { + root: { + watchEvents: new Map([ + ['/testRoot/foo', fooWatcher], + ['/testRoot/bar', barWatcher], + ]), + }, + }; + + (debugVariablesModelService as any).queueChangeEvent('/testRoot/foo', jest.fn()); + await jest.advanceTimersByTimeAsync(100); + expect(fooWatcher.callback).toHaveBeenCalledTimes(1); + + (debugVariablesModelService as any).queueChangeEvent('/testRoot/bar', barCallback); + await debugVariablesModelService.flushEventQueue(); + flushDeferred.resolve(); + await jest.advanceTimersByTimeAsync(100); + + expect(barWatcher.callback).toHaveBeenCalledTimes(1); + expect(barCallback).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('does not dedupe sibling paths that only share a string prefix', async () => { + const fooWatcher = { + callback: jest.fn(async () => {}), + }; + const foobarWatcher = { + callback: jest.fn(async () => {}), + }; + + (debugVariablesModelService as any)._activeTreeModel = { + root: { + watchEvents: new Map([ + ['/testRoot/foo', fooWatcher], + ['/testRoot/foobar', foobarWatcher], + ]), + }, + }; + (debugVariablesModelService as any)._changeEventDispatchQueue = ['/testRoot/foo', '/testRoot/foobar']; + + await debugVariablesModelService.flushEventQueue(); + + expect(fooWatcher.callback).toHaveBeenCalledTimes(1); + expect(foobarWatcher.callback).toHaveBeenCalledTimes(1); + }); + + it('restores expanded Locals and Globals from cached scope state', async () => { + const localsRawScope = { name: 'Locals', expensive: false }; + const globalsRawScope = { name: 'Globals', expensive: false }; + const oldLocalsScope = { + expanded: true, + variablesReference: 1, + getRawScope: () => localsRawScope, + }; + const oldGlobalsScope = { + expanded: true, + variablesReference: 2, + getRawScope: () => globalsRawScope, + }; + const refreshedLocalsScope = { + expanded: false, + variablesReference: 101, + children: [], + setExpanded: jest.fn(async () => { + refreshedLocalsScope.expanded = true; + }), + getRawScope: () => ({ name: 'Locals', expensive: false }), + }; + const refreshedGlobalsScope = { + expanded: false, + variablesReference: 102, + children: [], + setExpanded: jest.fn(async () => { + refreshedGlobalsScope.expanded = true; + }), + getRawScope: () => ({ name: 'Globals', expensive: false }), + }; + const refreshedClosureScope = { + expanded: false, + variablesReference: 103, + children: [], + setExpanded: jest.fn(async () => { + refreshedClosureScope.expanded = true; + }), + getRawScope: () => ({ name: 'Closure', expensive: false }), + }; + + (debugVariablesModelService as any).keepExpandedScopesModel.set(oldLocalsScope); + (debugVariablesModelService as any).keepExpandedScopesModel.set(oldGlobalsScope); + + await (debugVariablesModelService as any).restoreExpandedScopes([ + refreshedLocalsScope, + refreshedGlobalsScope, + refreshedClosureScope, + ]); + + expect(refreshedLocalsScope.setExpanded).toHaveBeenCalledTimes(1); + expect(refreshedGlobalsScope.setExpanded).toHaveBeenCalledTimes(1); + expect(refreshedClosureScope.setExpanded).not.toHaveBeenCalled(); + }); + it('initTreeModel method should be work', () => { const mockSession = { on: jest.fn(), diff --git a/packages/debug/src/browser/debug-session.ts b/packages/debug/src/browser/debug-session.ts index 9a8dd16883..a8a3eff364 100644 --- a/packages/debug/src/browser/debug-session.ts +++ b/packages/debug/src/browser/debug-session.ts @@ -1012,6 +1012,9 @@ export class DebugSession implements IDebugSession { // 在 VS Code JavaScript Debugger 中,如果一个表达式取值为 `undefined`, 这里将不会返回结果 const response = await this.sendRequest('evaluate', { expression, frameId, context }); + if (context === 'repl') { + this._onVariableChange.fire(); + } return response.body; } diff --git a/packages/debug/src/browser/view/variables/debug-variables-tree.model.service.ts b/packages/debug/src/browser/view/variables/debug-variables-tree.model.service.ts index 4117662717..d6cb4d3a88 100644 --- a/packages/debug/src/browser/view/variables/debug-variables-tree.model.service.ts +++ b/packages/debug/src/browser/view/variables/debug-variables-tree.model.service.ts @@ -8,6 +8,7 @@ import { TreeModel, TreeNodeEvent, TreeNodeType, + WatchEvent, } from '@opensumi/ide-components'; import { Deferred, @@ -16,6 +17,8 @@ import { Event, IClipboardService, ThrottledDelayer, + pSeries, + path, } from '@opensumi/ide-core-browser'; import { AbstractContextMenuService, ICtxMenuRenderer, MenuId } from '@opensumi/ide-core-browser/lib/menu/next'; import { DebugProtocol } from '@opensumi/vscode-debugprotocol'; @@ -35,48 +38,70 @@ import { DebugContextKey } from './../../contextkeys/debug-contextkey.service'; import { DebugVariablesModel } from './debug-variables-model'; import styles from './debug-variables.module.less'; +const { Path } = path; + export interface IDebugVariablesHandle extends IRecycleTreeHandle { hasDirectFocus: () => boolean; } export type DebugVariableWithRawScope = DebugScope | DebugVariableContainer; +interface IKeepExpandedScopeState { + expandedVariables: number[]; + scopeExpanded: boolean; +} + class KeepExpandedScopesModel { - private _keepExpandedScopesMap = new Map>(); + private _keepExpandedScopesMap = new Map(); constructor() {} private getMirrorScope(item: DebugVariableWithRawScope) { return Array.from(this._keepExpandedScopesMap.keys()).find((f) => isEqual(f, item.getRawScope())); } + private isTopLevelScope(item: DebugVariableWithRawScope) { + return !item.parent || DebugVariableRoot.is(item.parent as ExpressionContainer); + } + set(item: DebugVariableWithRawScope): void { const scope = item.getRawScope(); if (scope) { const keepScope = this.getMirrorScope(item); - if (keepScope) { - const kScopeVars = this._keepExpandedScopesMap.get(keepScope)!; - let nScopeVars: number[]; - if (item.expanded) { - nScopeVars = Array.from(new Set([...kScopeVars, item.variablesReference])); - } else { - nScopeVars = kScopeVars.filter((v) => v !== item.variablesReference); - } - this._keepExpandedScopesMap.set(keepScope, nScopeVars); + const targetScope = keepScope || scope; + const state = this._keepExpandedScopesMap.get(targetScope) || { + expandedVariables: [], + scopeExpanded: false, + }; + + if (this.isTopLevelScope(item)) { + state.scopeExpanded = item.expanded; } else { - this._keepExpandedScopesMap.set(scope, item.expanded ? [item.variablesReference] : []); + state.expandedVariables = item.expanded + ? Array.from(new Set([...state.expandedVariables, item.variablesReference])) + : state.expandedVariables.filter((v) => v !== item.variablesReference); } + + this._keepExpandedScopesMap.set(targetScope, state); } } - get(item: DebugVariableWithRawScope): number[] { + getExpandedVariables(item: DebugVariableWithRawScope): number[] { const keepScope = this.getMirrorScope(item); if (keepScope) { - return this._keepExpandedScopesMap.get(keepScope) || []; + return this._keepExpandedScopesMap.get(keepScope)?.expandedVariables || []; } else { return []; } } + isScopeExpanded(item: DebugVariableWithRawScope): boolean { + const keepScope = this.getMirrorScope(item); + if (keepScope) { + return !!this._keepExpandedScopesMap.get(keepScope)?.scopeExpanded; + } + return false; + } + clear(): void { this._keepExpandedScopesMap.clear(); } @@ -84,6 +109,7 @@ class KeepExpandedScopesModel { @Injectable() export class DebugVariablesModelService { + private static DEFAULT_REFRESH_DELAY = 100; private static DEFAULT_TRIGGER_DELAY = 200; @Autowired(INJECTOR_TOKEN) @@ -111,6 +137,11 @@ export class DebugVariablesModelService { private _currentVariableInternalContext: DebugVariable | DebugVariableContainer | undefined; public flushEventQueueDeferred: Deferred | null; + private _eventFlushTimeout: number; + private _changeEventDispatchQueue: string[] = []; + private _pendingFlushCallbacks: Array<() => Promise | void> = []; + private _isFlushingEventQueue = false; + private _disposed = false; // 装饰器 private selectedDecoration: Decoration = new Decoration(styles.mod_selected); // 选中态 @@ -130,10 +161,13 @@ export class DebugVariablesModelService { private flushDispatchChangeDelayer = new ThrottledDelayer(DebugVariablesModelService.DEFAULT_TRIGGER_DELAY); private disposableCollection: DisposableCollection = new DisposableCollection(); + private currentSessionDisposableCollection: DisposableCollection = new DisposableCollection(); + private currentSession: DebugSession | undefined; private keepExpandedScopesModel: KeepExpandedScopesModel = new KeepExpandedScopesModel(); constructor() { + this.listenCurrentSessionVariableChange(); this.listenViewModelChange(); } @@ -181,17 +215,45 @@ export class DebugVariablesModelService { } dispose() { + this._disposed = true; + this.disposeEventQueue(); + this.disposeTreeListeners(); + if (!this.currentSessionDisposableCollection.disposed) { + this.currentSessionDisposableCollection.dispose(); + } + } + + private disposeEventQueue() { + clearTimeout(this._eventFlushTimeout); + this._changeEventDispatchQueue = []; + this._pendingFlushCallbacks = []; + this._isFlushingEventQueue = false; + + const flushEventQueueDeferred = this.flushEventQueueDeferred; + this.flushEventQueueDeferred = null; + flushEventQueueDeferred?.resolve(); + } + + private disposeTreeListeners() { if (!this.disposableCollection.disposed) { this.disposableCollection.dispose(); } + this.disposableCollection = new DisposableCollection(); } listenViewModelChange() { this.viewModel.onDidChange(async () => { + if (this._disposed) { + return; + } + this.listenCurrentSessionVariableChange(); if (!this.flushDispatchChangeDelayer.isTriggered()) { this.flushDispatchChangeDelayer.cancel(); } this.flushDispatchChangeDelayer.trigger(async () => { + if (this._disposed) { + return; + } if (this.viewModel && this.viewModel.currentSession && !this.viewModel.currentSession.terminated) { const currentTreeModel = await this.initTreeModel(this.viewModel.currentSession); this._activeTreeModel = currentTreeModel; @@ -209,24 +271,7 @@ export class DebugVariablesModelService { } } } - - const execExpands = async (data: Array) => { - for (const s of data) { - const cacheExpands = this.keepExpandedScopesModel.get(s); - if (cacheExpands.includes(s.variablesReference)) { - await s.setExpanded(true); - if (Array.isArray(s.children)) { - await execExpands(s.children as Array); - } - } - } - }; - - scopes.forEach(async (s) => { - if (Array.isArray(s.children)) { - await execExpands(s.children as Array); - } - }); + await this.restoreExpandedScopes(scopes); } else { this._activeTreeModel = undefined; this.keepExpandedScopesModel.clear(); @@ -237,8 +282,29 @@ export class DebugVariablesModelService { }); } + private listenCurrentSessionVariableChange() { + if (this.currentSession === this.viewModel.currentSession) { + return; + } + + this.currentSession = this.viewModel.currentSession; + this.currentSessionDisposableCollection.dispose(); + this.currentSessionDisposableCollection = new DisposableCollection(); + + if (this.currentSession) { + const session = this.currentSession; + this.currentSessionDisposableCollection.push( + session.onVariableChange(() => { + if (session === this.currentSession && session === this.viewModel.currentSession && !session.terminated) { + this.refresh(); + } + }), + ); + } + } + listenTreeViewChange() { - this.dispose(); + this.disposeTreeListeners(); if (!this.treeModel) { return; } @@ -267,6 +333,170 @@ export class DebugVariablesModelService { return this._activeTreeModel; } + private isPreservedRootScope(scope: DebugVariableWithRawScope) { + const rawScope = scope.getRawScope(); + return ( + !!rawScope && + (rawScope.name === 'Locals' || rawScope.name === 'Globals') && + (!scope.parent || DebugVariableRoot.is(scope.parent as ExpressionContainer)) + ); + } + + private async restoreExpandedScopes(scopes: Array) { + for (const scope of scopes) { + if (this.isPreservedRootScope(scope) && this.keepExpandedScopesModel.isScopeExpanded(scope) && !scope.expanded) { + await scope.setExpanded(true); + } + + const cacheExpands = this.keepExpandedScopesModel.getExpandedVariables(scope); + const children = (scope.children || []) as Array; + for (const child of children) { + if (cacheExpands.includes(child.variablesReference)) { + await child.setExpanded(true); + if (Array.isArray(child.children)) { + await this.restoreExpandedScopes(child.children as Array); + } + } + } + } + } + + /** + * 刷新指定节点下的所有子节点 + */ + async refresh(node?: ExpressionContainer) { + if (this._disposed) { + return; + } + if (!this.isActiveTreeModelForCurrentSession()) { + return; + } + if (!node) { + if (this.treeModel) { + node = this.treeModel.root as ExpressionContainer; + } else { + return; + } + } + if (!ExpressionContainer.is(node) && (node as ExpressionContainer).parent) { + node = (node as ExpressionContainer).parent as ExpressionContainer; + } + return this.queueChangeEvent(node.path, async () => { + const scopes = (this.treeModel?.root.children as Array) || []; + await this.restoreExpandedScopes(scopes); + this.onDidRefreshedEmitter.fire(); + }); + } + + private isActiveTreeModelForCurrentSession() { + const treeSession = (this.treeModel?.root as DebugVariableRoot | undefined)?.session; + if (!treeSession) { + return true; + } + return ( + treeSession === this.currentSession && treeSession === this.viewModel.currentSession && !treeSession.terminated + ); + } + + private queueChangeEvent(path: string, callback: () => Promise | void) { + if (this._disposed) { + return Promise.resolve(); + } + if (this._changeEventDispatchQueue.indexOf(path) === -1) { + this._changeEventDispatchQueue.push(path); + } + this._pendingFlushCallbacks.push(callback); + + if (!this.flushEventQueueDeferred) { + this.flushEventQueueDeferred = new Deferred(); + clearTimeout(this._eventFlushTimeout); + this._eventFlushTimeout = setTimeout(async () => { + try { + if (this._disposed) { + return; + } + this._isFlushingEventQueue = true; + while ( + !this._disposed && + (this._changeEventDispatchQueue.length > 0 || this._pendingFlushCallbacks.length > 0) + ) { + const pendingFlushCallbacks = [...this._pendingFlushCallbacks]; + this._pendingFlushCallbacks = []; + + await this.flushQueuedEventBatch(); + if (this._disposed) { + return; + } + await pSeries( + pendingFlushCallbacks.map((pendingFlushCallback) => async () => { + if (!this._disposed) { + await pendingFlushCallback(); + } + return null; + }), + ); + } + + this.flushEventQueueDeferred?.resolve(); + } catch (error) { + if (!this._disposed) { + this.flushEventQueueDeferred?.reject(error); + } + } finally { + this._isFlushingEventQueue = false; + this.flushEventQueueDeferred = null; + } + }, DebugVariablesModelService.DEFAULT_REFRESH_DELAY) as any; + } + + return this.flushEventQueueDeferred.promise; + } + + private isSameOrParentPath(basePath: string, targetPath: string) { + const base = new Path(basePath); + const target = new Path(targetPath); + return base.isEqual(target) || base.isEqualOrParent(target); + } + + public flushEventQueue = () => { + if (this._disposed || this._isFlushingEventQueue) { + return; + } + return this.flushQueuedEventBatch(); + }; + + private flushQueuedEventBatch = () => { + if (this._disposed || !this._changeEventDispatchQueue || this._changeEventDispatchQueue.length === 0) { + return; + } + + const queuedPaths = [...this._changeEventDispatchQueue]; + this._changeEventDispatchQueue = []; + + queuedPaths.sort((pathA, pathB) => { + const pathADepth = Path.pathDepth(pathA); + const pathBDepth = Path.pathDepth(pathB); + return pathADepth - pathBDepth; + }); + const roots = [queuedPaths[0]]; + for (const path of queuedPaths) { + if (roots.some((root) => this.isSameOrParentPath(root, path))) { + continue; + } else { + roots.push(path); + } + } + return pSeries( + roots.map((path) => async () => { + const watcher = this.treeModel?.root?.watchEvents.get(path); + if (watcher && typeof watcher.callback === 'function') { + await watcher.callback({ type: WatchEvent.Changed, path }); + } + return null; + }), + ); + }; + initDecorations(root) { this._decorations = new DecorationsManager(root as any); this._decorations.addDecoration(this.selectedDecoration);