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);
+ });
+ });
});