From dab8e6643c5ea53644764b2198ba853bdb90b31e Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 21:40:10 -0400 Subject: [PATCH] fix(file-viewer): COD-341 deduplicate normal tree loads --- src/web/public/panels-ui.js | 251 ++++++++++++++++++++++----- test/file-browser-hidden.test.ts | 9 +- test/file-browser-search.test.ts | 284 ++++++++++++++++++++++++++++++- 3 files changed, 490 insertions(+), 54 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 237c09e3..26ffa2c1 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2990,8 +2990,9 @@ Object.assign(CodemanApp.prototype, { ownerSessionId, view: 'normal', normalState: this.fileBrowserData - ? { ownerSessionId, showHidden, data: this.fileBrowserData } + ? { sessionId: ownerSessionId, showHidden, treeEpoch: 0, phase: 'ready', data: this.fileBrowserData } : null, + treeInFlight: null, inFlight: null, matches: [], deferredDirectoryTarget: null, @@ -3013,6 +3014,104 @@ Object.assign(CodemanApp.prototype, { statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`; }, + _isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) { + return ( + candidate?.sessionId === sessionId && + candidate.showHidden === showHidden && + candidate.treeEpoch === treeEpoch + ); + }, + + _isFileBrowserTreeContextCurrent(request, requireCurrentRecord = false) { + const state = this._ensureFileBrowserState(); + return ( + (!requireCurrentRecord || state.treeInFlight === request) && + state.ownerSessionId === request.sessionId && + state.treeEpoch === request.treeEpoch && + (this.fileBrowserShowHidden === true) === request.showHidden + ); + }, + + _canRenderFileBrowserNormal(normalState) { + const state = this._ensureFileBrowserState(); + return ( + state.view === 'normal' && + this.activeSessionId === normalState?.sessionId && + this._isFileBrowserNormalCompatible( + normalState, + state.ownerSessionId, + this.fileBrowserShowHidden === true, + state.treeEpoch, + ) && + this.$('fileBrowserPanel')?.classList.contains('visible') === true + ); + }, + + _renderFileBrowserNormalState(normalState) { + if (!normalState || !this._canRenderFileBrowserNormal(normalState)) return; + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + if (!treeEl) return; + + if (normalState.phase === 'loading') { + this.fileBrowserData = null; + treeEl.innerHTML = `
${escapeHtml('Loading files...')}
`; + if (statusEl) statusEl.textContent = 'Loading files...'; + return; + } + + if (normalState.phase === 'error') { + this.fileBrowserData = null; + const detail = normalState.error && normalState.error !== 'Failed to load files' + ? `: ${normalState.error}` + : ''; + const message = `Failed to load files${detail}`; + treeEl.innerHTML = `
${escapeHtml(message)}
`; + if (statusEl) statusEl.textContent = message; + return; + } + + if (normalState.phase !== 'ready') return; + this.fileBrowserData = normalState.data; + this._setFileBrowserExpandDisabled(false); + this.renderFileBrowserTree(normalState.sessionId); + this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden); + }, + + _validateFileBrowserTreeEnvelope(result) { + if (!result || typeof result !== 'object' || result.success !== true) return null; + const data = result.data; + if (!data || typeof data !== 'object' || !Array.isArray(data.tree)) return null; + if (data.mode === 'search') return null; + if ( + typeof data.totalFiles !== 'number' || + !Number.isFinite(data.totalFiles) || + data.totalFiles < 0 || + typeof data.totalDirectories !== 'number' || + !Number.isFinite(data.totalDirectories) || + data.totalDirectories < 0 || + typeof data.truncated !== 'boolean' + ) { + return null; + } + + const validNodes = nodes => nodes.every(node => { + if (!node || typeof node !== 'object') return false; + if (typeof node.name !== 'string' || typeof node.path !== 'string') return false; + if (node.type !== 'file' && node.type !== 'directory') return false; + if (node.size !== undefined && (typeof node.size !== 'number' || !Number.isFinite(node.size))) return false; + if (node.extension !== undefined && typeof node.extension !== 'string') return false; + if (node.children !== undefined && (!Array.isArray(node.children) || !validNodes(node.children))) return false; + return true; + }); + + return validNodes(data.tree) ? data : null; + }, + + _normalizeFileBrowserTreeError(error) { + return typeof error?.message === 'string' && error.message ? error.message : 'Failed to load files'; + }, + _validateFileBrowserSearchEnvelope(result) { if (!result || typeof result !== 'object' || result.success !== true) return null; const data = result.data; @@ -3039,7 +3138,6 @@ Object.assign(CodemanApp.prototype, { const panel = this.$('fileBrowserPanel'); return ( state.searchEpoch === request.epoch && - state.treeEpoch === request.treeEpoch && state.ownerSessionId === request.ownerSessionId && this.activeSessionId === request.ownerSessionId && (this.fileBrowserShowHidden === true) === request.showHidden && @@ -3064,46 +3162,89 @@ Object.assign(CodemanApp.prototype, { this._syncFileBrowserHiddenBtn(); // Expanded-directory state is deliberately preserved so toggling does not // collapse the tree the user just navigated. - if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId); + if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId, { force: true }); }, - async loadFileBrowser(sessionId) { - if (!sessionId) return; + loadFileBrowser(sessionId, { force = false } = {}) { + if (!sessionId) return undefined; const state = this._ensureFileBrowserState(); - state.treeEpoch++; - state.ownerSessionId = sessionId; const treeEl = this.$('fileBrowserTree'); - const statusEl = this.$('fileBrowserStatus'); this._syncFileBrowserHiddenBtn(); - if (!treeEl) return; + if (!treeEl) return undefined; + if (!state.ownerSessionId) state.ownerSessionId = sessionId; + if (state.ownerSessionId !== sessionId) return undefined; - // Show loading state - treeEl.innerHTML = '
Loading files...
'; - - try { - const showHidden = this.fileBrowserShowHidden === true; - const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`); - if (!res.ok) throw new Error('Failed to load files'); - - const result = await res.json(); - if (!result.success) throw new Error(result.error || 'Failed to load files'); - - this.fileBrowserData = result.data; - state.normalState = { ownerSessionId: sessionId, showHidden, data: result.data }; - if (state.filter.trim() === '') { - state.view = 'normal'; - state.matches = []; - this._setFileBrowserExpandDisabled(false); - this.renderFileBrowserTree(sessionId); - } - - // Update status - if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden); - } catch (err) { - console.error('Failed to load file browser:', err); - treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; + if (force) state.treeEpoch++; + const showHidden = this.fileBrowserShowHidden === true; + const treeEpoch = state.treeEpoch; + const inFlight = state.treeInFlight; + if ( + !force && + this._isFileBrowserNormalCompatible(inFlight, sessionId, showHidden, treeEpoch) + ) { + return inFlight.promise; } + + const settled = state.normalState; + if ( + !force && + this._isFileBrowserNormalCompatible(settled, sessionId, showHidden, treeEpoch) && + (settled.phase === 'ready' || settled.phase === 'error') + ) { + if (settled.phase === 'ready') this.fileBrowserData = settled.data; + this._renderFileBrowserNormalState(settled); + return Promise.resolve(settled); + } + + const loadingState = { sessionId, showHidden, treeEpoch, phase: 'loading' }; + state.normalState = loadingState; + this.fileBrowserData = null; + this._renderFileBrowserNormalState(loadingState); + + const record = { sessionId, showHidden, treeEpoch, promise: null }; + const request = (async () => { + try { + const res = await fetch( + `/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`, + ); + if (!res.ok) throw new Error('Failed to load files'); + const result = await res.json(); + const data = this._validateFileBrowserTreeEnvelope(result); + if (!data) { + const detail = result && typeof result === 'object' && typeof result.error === 'string' + ? result.error + : 'Failed to load files'; + throw new Error(detail); + } + if (!this._isFileBrowserTreeContextCurrent(record, true)) return; + + const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data }; + state.normalState = nextNormalState; + this.fileBrowserData = data; + this._completeDeferredFileBrowserDirectory?.(nextNormalState); + this._renderFileBrowserNormalState(nextNormalState); + } catch (error) { + if (!this._isFileBrowserTreeContextCurrent(record, true)) return; + const nextNormalState = { + sessionId, + showHidden, + treeEpoch, + phase: 'error', + error: this._normalizeFileBrowserTreeError(error), + }; + state.normalState = nextNormalState; + this.fileBrowserData = null; + this._completeDeferredFileBrowserDirectory?.(nextNormalState); + console.error('Failed to load file browser:', error); + this._renderFileBrowserNormalState(nextNormalState); + } + })(); + record.promise = request.finally(() => { + if (state.treeInFlight === record) state.treeInFlight = null; + }); + state.treeInFlight = record; + return record.promise; }, renderFileBrowserTree(ownerSessionId) { @@ -3111,7 +3252,7 @@ Object.assign(CodemanApp.prototype, { if (!treeEl || !this.fileBrowserData) return; const state = this._ensureFileBrowserState(); - const owner = ownerSessionId || state.normalState?.ownerSessionId || state.ownerSessionId || this.activeSessionId; + const owner = ownerSessionId || state.normalState?.sessionId || state.ownerSessionId || this.activeSessionId; if (!owner) return; const { tree } = this.fileBrowserData; @@ -3216,12 +3357,14 @@ Object.assign(CodemanApp.prototype, { const normal = state.normalState; if ( ownerSessionId && - normal?.ownerSessionId === ownerSessionId && - normal.showHidden === (this.fileBrowserShowHidden === true) + this._isFileBrowserNormalCompatible( + normal, + ownerSessionId, + this.fileBrowserShowHidden === true, + state.treeEpoch, + ) ) { - this.fileBrowserData = normal.data; - this.renderFileBrowserTree(ownerSessionId); - this._renderFileBrowserNormalStatus(normal.data, normal.showHidden); + this._renderFileBrowserNormalState(normal); } return; } @@ -3366,14 +3509,28 @@ Object.assign(CodemanApp.prototype, { }, refreshFileBrowser() { - if (this.activeSessionId) { - this.fileBrowserExpandedDirs.clear(); - this.fileBrowserFilter = ''; - this.fileBrowserAllExpanded = false; - const searchInput = this.$('fileBrowserSearch'); - if (searchInput) searchInput.value = ''; - this.loadFileBrowser(this.activeSessionId); + const state = this._ensureFileBrowserState(); + if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) { + clearTimeout(state.inFlight.timer); } + state.inFlight = null; + state.searchEpoch++; + state.filter = ''; + state.matches = []; + state.deferredDirectoryTarget = null; + state.view = 'normal'; + this.fileBrowserFilter = ''; + this.fileBrowserExpandedDirs.clear(); + this.fileBrowserAllExpanded = false; + this._setFileBrowserExpandDisabled(false); + const expandBtn = this.$('fileBrowserExpandBtn'); + if (expandBtn) expandBtn.innerHTML = '\u229E'; + const searchInput = this.$('fileBrowserSearch'); + if (searchInput) searchInput.value = ''; + + const ownerSessionId = state.ownerSessionId || this.activeSessionId; + if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined; + return this.loadFileBrowser(ownerSessionId, { force: true }); }, // Header "File Viewer" button (opt-in via App Settings → Header Displays → diff --git a/test/file-browser-hidden.test.ts b/test/file-browser-hidden.test.ts index f5817fc3..a867d680 100644 --- a/test/file-browser-hidden.test.ts +++ b/test/file-browser-hidden.test.ts @@ -38,7 +38,7 @@ interface FakeElement { textContent: string; classes: Set; attrs: Record; - classList: { toggle: (name: string, on: boolean) => void }; + classList: { toggle: (name: string, on: boolean) => void; contains: (name: string) => boolean }; setAttribute: (name: string, value: string) => void; } @@ -55,6 +55,9 @@ function fakeElement(): FakeElement { if (on) classes.add(name); else classes.delete(name); }, + contains(name: string) { + return classes.has(name); + }, }, setAttribute(name: string, value: string) { attrs[name] = value; @@ -92,10 +95,12 @@ function loadPanel(store: Map | null) { vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' }); const elements: Record = { + fileBrowserPanel: fakeElement(), fileBrowserTree: fakeElement(), fileBrowserStatus: fakeElement(), fileBrowserHiddenBtn: fakeElement(), }; + elements.fileBrowserPanel.classList.toggle('visible', true); const requests: string[] = []; const app = new CodemanApp() as Record; app.$ = (id: string) => elements[id] ?? null; @@ -149,10 +154,12 @@ describe('File Viewer show-hidden toggle', () => { const { app, requests } = loadPanel(store); await app.loadFileBrowser('sess-1'); expect(requests[0]).toContain('showHidden=false'); + const previousTreeEpoch = app._fileBrowserState.treeEpoch; await app.toggleFileBrowserHidden(); expect(app.fileBrowserShowHidden).toBe(true); + expect(app._fileBrowserState.treeEpoch).toBe(previousTreeEpoch + 1); expect(requests).toHaveLength(2); expect(requests[1]).toContain('showHidden=true'); expect(store.get(STORAGE_KEY)).toBe('1'); diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index b3ffe5b2..f144a090 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -207,6 +207,19 @@ function successfulData(overrides: Record = {}) { }; } +function successfulTree(name: string, overrides: Record = {}) { + return { + success: true, + data: { + tree: [{ name, path: name, type: 'file', size: 1, extension: 'ts' }], + totalFiles: 1, + totalDirectories: 0, + truncated: false, + ...overrides, + }, + }; +} + describe('File Viewer server search', () => { beforeEach(() => { vi.useFakeTimers(); @@ -356,12 +369,6 @@ describe('File Viewer server search', () => { app._fileBrowserState.searchEpoch++; }, }, - { - name: 'tree epoch', - mutate: (app) => { - app._fileBrowserState.treeEpoch++; - }, - }, { name: 'owner', mutate: (app) => { @@ -584,4 +591,269 @@ describe('File Viewer server search', () => { ); expect(html).not.toContain(' { + it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => { + const ready = loadPanel(); + ready.app._completeDeferredFileBrowserDirectory = vi.fn(); + const first = ready.app.loadFileBrowser('session/A'); + const duplicate = ready.app.loadFileBrowser('session/A'); + + expect(duplicate).toBe(first); + expect(ready.pending).toHaveLength(1); + ready.pending[0].reply.resolve(response(successfulTree('ready.ts'))); + await first; + expect(ready.app._fileBrowserState.normalState.phase).toBe('ready'); + expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); + expect(ready.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( + ready.app._fileBrowserState.normalState + ); + + ready.elements.fileBrowserTree.innerHTML = ''; + const reusedReady = ready.app.loadFileBrowser('session/A'); + expect(ready.pending).toHaveLength(1); + expect(typeof reusedReady.then).toBe('function'); + await reusedReady; + expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); + + const failed = loadPanel(); + failed.app._completeDeferredFileBrowserDirectory = vi.fn(); + const failedLoad = failed.app.loadFileBrowser('session/A'); + failed.pending[0].reply.reject(new Error('')); + await failedLoad; + expect(failed.app._fileBrowserState.normalState.phase).toBe('error'); + expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); + expect(failed.elements.fileBrowserTree.innerHTML).not.toContain(''); + expect(failed.elements.fileBrowserStatus.innerHTML).toBe(''); + expect(failed.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( + failed.app._fileBrowserState.normalState + ); + + failed.elements.fileBrowserTree.innerHTML = ''; + await failed.app.loadFileBrowser('session/A'); + expect(failed.pending).toHaveLength(1); + expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); + }); + + it('refresh invalidates both work classes, clears navigation state, and forces an epoch-safe replacement', async () => { + const { app, elements, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + app.filterFileBrowser('old query'); + app.fileBrowserExpandedDirs.add('src'); + app.fileBrowserAllExpanded = true; + app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' }; + const beforeSearchEpoch = app._fileBrowserState.searchEpoch; + const beforeTreeEpoch = app._fileBrowserState.treeEpoch; + + const replacement = app.refreshFileBrowser(); + + expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1); + expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1); + expect(app._fileBrowserState.filter).toBe(''); + expect(app._fileBrowserState.matches).toEqual([]); + expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); + expect(app._fileBrowserState.view).toBe('normal'); + expect(app.fileBrowserExpandedDirs.size).toBe(0); + expect(app.fileBrowserAllExpanded).toBe(false); + expect(elements.fileBrowserExpandBtn.disabled).toBe(false); + expect(elements.fileBrowserSearch.value).toBe(''); + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(pending).toHaveLength(2); + + pending[0].reply.resolve(response(successfulTree('stale.ts'))); + await stale; + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); + expect(app._fileBrowserState.treeInFlight.promise).toBe(replacement); + + pending[1].reply.resolve(response(successfulTree('fresh.ts'))); + await replacement; + expect(elements.fileBrowserTree.innerHTML).toContain('fresh.ts'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); + }); + + it('ignores stale pre-refresh failures while the replacement remains loading', async () => { + const { app, elements, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + const replacement = app.refreshFileBrowser(); + + pending[0].reply.reject(new Error('stale failure')); + await stale; + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale failure'); + + pending[1].reply.resolve(response(successfulTree('replacement.ts'))); + await replacement; + expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts'); + }); + + it('isolates overlapping owners and does not reuse an unresolved A request after A to B to A', async () => { + const { app, elements, pending } = loadPanel(); + const firstA = app.loadFileBrowser('session/A'); + app._fileBrowserState.ownerSessionId = 'session/B'; + app.activeSessionId = 'session/B'; + const loadB = app.loadFileBrowser('session/B'); + + app._fileBrowserState.ownerSessionId = 'session/A'; + app.activeSessionId = 'session/A'; + const secondA = app.loadFileBrowser('session/A'); + expect(secondA).not.toBe(firstA); + expect(pending).toHaveLength(3); + + pending[0].reply.resolve(response(successfulTree('stale-A.ts'))); + await firstA; + expect(app.fileBrowserData).toBeNull(); + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts'); + + pending[1].reply.reject(new Error('late B failure')); + await loadB; + expect(elements.fileBrowserTree.innerHTML).not.toContain('late B failure'); + pending[2].reply.resolve(response(successfulTree('current-A.ts'))); + await secondA; + expect(elements.fileBrowserTree.innerHTML).toContain('current-A.ts'); + }); + + it('shows B loading after clearing search while B is unsettled, never cached A data', async () => { + const { app, elements, pending } = loadPanel(); + const firstA = app.loadFileBrowser('session/A'); + pending[0].reply.resolve(response(successfulTree('cached-A.ts'))); + await firstA; + expect(elements.fileBrowserTree.innerHTML).toContain('cached-A.ts'); + + app._fileBrowserState.ownerSessionId = 'session/B'; + app.activeSessionId = 'session/B'; + const loadB = app.loadFileBrowser('session/B'); + app.filterFileBrowser('temporary'); + app.filterFileBrowser(''); + + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-A.ts'); + pending[1].reply.resolve(response(successfulTree('current-B.ts'))); + await loadB; + expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts'); + }); + + it('settles a compatible tree behind search without repainting and keeps tree/search epochs independent', async () => { + const { app, elements, pending } = loadPanel(); + const treeLoad = app.loadFileBrowser('session/A'); + app.filterFileBrowser('query A'); + await vi.advanceTimersByTimeAsync(250); + app.filterFileBrowser('query B'); + await vi.advanceTimersByTimeAsync(250); + expect(pending).toHaveLength(3); + + pending[0].reply.resolve(response(successfulTree('behind-search.ts'))); + await treeLoad; + expect(app._fileBrowserState.normalState.phase).toBe('ready'); + expect(app.fileBrowserData.tree[0].name).toBe('behind-search.ts'); + expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-search.ts'); + + app._fileBrowserState.treeEpoch++; + pending[2].reply.resolve( + response(successfulData({ matches: [{ name: 'query-B.ts', path: 'query-B.ts', type: 'file' }] })) + ); + await vi.advanceTimersByTimeAsync(0); + expect(elements.fileBrowserTree.innerHTML).toContain('query-B.ts'); + + pending[1].reply.resolve( + response(successfulData({ matches: [{ name: 'query-A.ts', path: 'query-A.ts', type: 'file' }] })) + ); + await vi.advanceTimersByTimeAsync(0); + expect(elements.fileBrowserTree.innerHTML).not.toContain('query-A.ts'); + }); + + it('suppresses invalidated tree responses and hidden-panel repainting', async () => { + const scenarios = [ + { + name: 'owner', + mutate(app: Record) { + app._fileBrowserState.ownerSessionId = 'session/B'; + }, + }, + { + name: 'tree epoch', + mutate(app: Record) { + app._fileBrowserState.treeEpoch++; + }, + }, + { + name: 'hidden preference', + mutate(app: Record) { + app.fileBrowserShowHidden = true; + }, + }, + ]; + + for (const scenario of scenarios) { + const { app, elements, pending } = loadPanel(); + const load = app.loadFileBrowser('session/A'); + scenario.mutate(app); + pending[0].reply.resolve(response(successfulTree(`late-${scenario.name}.ts`))); + await load; + expect(app.fileBrowserData, scenario.name).toBeNull(); + expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`); + } + + const hidden = loadPanel(); + const hiddenLoad = hidden.app.loadFileBrowser('session/A'); + hidden.elements.fileBrowserPanel.classList.remove('visible'); + hidden.pending[0].reply.resolve(response(successfulTree('hidden-ready.ts'))); + await hiddenLoad; + expect(hidden.app._fileBrowserState.normalState.phase).toBe('ready'); + expect(hidden.elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(hidden.elements.fileBrowserTree.innerHTML).not.toContain('hidden-ready.ts'); + }); + + it('compare-and-clears only the exact stale request and preserves replacement deduplication', async () => { + const { app, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + const replacement = app.loadFileBrowser('session/A', { force: true }); + const replacementRecord = app._fileBrowserState.treeInFlight; + + pending[0].reply.resolve(response(successfulTree('stale.ts'))); + await stale; + expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord); + + const reused = app.loadFileBrowser('session/A'); + expect(reused).toBe(replacement); + expect(pending).toHaveLength(2); + + pending[1].reply.resolve(response(successfulTree('replacement.ts'))); + await replacement; + }); + + it.each([ + ['tree is not an array', successfulTree('x.ts', { tree: {} })], + ['totalFiles is not finite', successfulTree('x.ts', { totalFiles: Number.POSITIVE_INFINITY })], + ['the envelope is search-discriminated', successfulTree('x.ts', { mode: 'search', matches: [], matchCount: 0 })], + [ + 'a nested node is malformed', + successfulTree('x.ts', { tree: [{ name: 'dir', path: 'dir', type: 'directory', children: [{}] }] }), + ], + ])('stores an escaped error instead of malformed tree data when %s', async (_case, body) => { + const { app, elements, pending } = loadPanel(); + const load = app.loadFileBrowser('session/A'); + pending[0].reply.resolve(response(body)); + await load; + + expect(app.fileBrowserData).toBeNull(); + expect(app._fileBrowserState.normalState.phase).toBe('error'); + expect(elements.fileBrowserTree.innerHTML).toContain('Failed to load files'); + }); + + it('does not adopt a different owner or fetch without a usable tree surface', () => { + const wrongOwner = loadPanel(); + wrongOwner.app._ensureFileBrowserState().ownerSessionId = 'session/B'; + expect(wrongOwner.app.loadFileBrowser('session/A')).toBeUndefined(); + expect(wrongOwner.pending).toHaveLength(0); + expect(wrongOwner.app._fileBrowserState.ownerSessionId).toBe('session/B'); + + const missingTree = loadPanel(); + delete missingTree.elements.fileBrowserTree; + expect(missingTree.app.loadFileBrowser('session/A')).toBeUndefined(); + expect(missingTree.pending).toHaveLength(0); + }); + }); });