${expandIcon}
${icon}
${escapeHtml(node.name)}
@@ -3102,21 +3460,12 @@ Object.assign(CodemanApp.prototype, {
if (type === 'directory') {
this.toggleFileBrowserFolder(path);
} else {
- this.openFilePreview(path);
+ this.openFilePreview(path, owner);
}
});
});
},
- hasMatchingChild(node, filter) {
- if (!node.children) return false;
- for (const child of node.children) {
- if (child.name.toLowerCase().includes(filter)) return true;
- if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true;
- }
- return false;
- },
-
toggleFileBrowserFolder(path) {
if (this.fileBrowserExpandedDirs.has(path)) {
this.fileBrowserExpandedDirs.delete(path);
@@ -3127,12 +3476,291 @@ Object.assign(CodemanApp.prototype, {
},
filterFileBrowser(value) {
- this.fileBrowserFilter = value;
- // Auto-expand all if filtering
- if (value) {
- this.expandAllDirectories(this.fileBrowserData?.tree || []);
+ const state = this._ensureFileBrowserState();
+ const rawInput = String(value ?? '');
+ const query = rawInput.trim();
+ state.searchEpoch++;
+ state.filter = rawInput;
+ state.deferredDirectoryTarget = null;
+ this.fileBrowserFilter = rawInput;
+ this._syncFileBrowserExpandBtn();
+
+ if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
+ clearTimeout(state.inFlight.timer);
+ }
+ state.inFlight = null;
+
+ if (!state.ownerSessionId && this.activeSessionId) state.ownerSessionId = this.activeSessionId;
+ const ownerSessionId = state.ownerSessionId || null;
+ if (!this.activeSessionId || !ownerSessionId || this.activeSessionId !== ownerSessionId) return;
+ if (!query) {
+ state.view = 'normal';
+ state.matches = [];
+ this._syncFileBrowserExpandBtn();
+ const normal = state.normalState;
+ if (
+ ownerSessionId &&
+ this._isFileBrowserNormalCompatible(
+ normal,
+ ownerSessionId,
+ this.fileBrowserShowHidden === true,
+ state.treeEpoch,
+ )
+ ) {
+ this._renderFileBrowserNormalState(normal);
+ }
+ return;
+ }
+
+ if (query.length > 256) {
+ const message = 'Search queries are limited to 256 characters';
+ state.view = 'query-error';
+ state.matches = [];
+ this._syncFileBrowserExpandBtn();
+ const treeEl = this.$('fileBrowserTree');
+ const statusEl = this.$('fileBrowserStatus');
+ if (treeEl) treeEl.innerHTML = `
${escapeHtml(message)}
`;
+ if (statusEl) statusEl.textContent = message;
+ return;
+ }
+
+ const panel = this.$('fileBrowserPanel');
+ const treeEl = this.$('fileBrowserTree');
+ if (!ownerSessionId || !panel || !treeEl) return;
+
+ const request = {
+ epoch: state.searchEpoch,
+ treeEpoch: state.treeEpoch,
+ ownerSessionId,
+ showHidden: this.fileBrowserShowHidden === true,
+ rawInput,
+ query,
+ timer: null,
+ };
+ state.view = 'search-pending';
+ state.matches = [];
+ state.inFlight = request;
+ this._syncFileBrowserExpandBtn();
+ treeEl.innerHTML = `
${escapeHtml('Searching...')}
`;
+ const statusEl = this.$('fileBrowserStatus');
+ if (statusEl) statusEl.textContent = 'Searching...';
+
+ request.timer = setTimeout(async () => {
+ request.timer = null;
+ try {
+ const res = await fetch(
+ `/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
+ );
+ if (!res.ok) throw new Error('Search failed');
+ const result = await res.json();
+ const data = this._validateFileBrowserSearchEnvelope(result);
+ if (!data) throw new Error('Search failed');
+ const canRender = this._canRenderFileBrowserSearch(request);
+ if (state.inFlight === request) state.inFlight = null;
+ if (!canRender) return;
+ state.view = 'search-results';
+ state.matches = data.matches;
+ this._renderFileBrowserSearchResults(data.matches, ownerSessionId, data);
+ } catch (err) {
+ const canRender = this._canRenderFileBrowserSearch(request);
+ if (state.inFlight === request) state.inFlight = null;
+ if (!canRender) return;
+ console.error('Failed to search file browser:', err);
+ state.view = 'search-error';
+ state.matches = [];
+ this._renderFileBrowserSearchError();
+ }
+ }, 250);
+ },
+
+ _renderFileBrowserSearchResults(matches, ownerSessionId, data) {
+ const treeEl = this.$('fileBrowserTree');
+ if (!treeEl || !ownerSessionId) return;
+ const state = this._ensureFileBrowserState();
+ const searchContext = {
+ ownerSessionId,
+ showHidden: this.fileBrowserShowHidden === true,
+ treeEpoch: state.treeEpoch,
+ searchEpoch: state.searchEpoch,
+ rawInput: state.filter,
+ query: state.filter.trim(),
+ view: state.view,
+ };
+ if (matches.length === 0) {
+ treeEl.innerHTML = `
${escapeHtml('No matches')}
`;
+ } else {
+ const ownerPath = encodeURIComponent(ownerSessionId);
+ treeEl.innerHTML = matches
+ .map(match => {
+ const isDir = match.type === 'directory';
+ const icon = isDir ? '๐' : this.getFileIcon(match.extension || '');
+ const sizeStr = !isDir && match.size !== undefined
+ ? `
${this.formatFileSize(match.size)}`
+ : '';
+ const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
+ const downloadBtn = !isDir
+ ? `
⬇`
+ : '';
+ return `
+
+
+ ${icon}
+ ${escapeHtml(match.name)}
+ ${sizeStr}
+ ${downloadBtn}
+
+ `;
+ })
+ .join('');
+ }
+
+ treeEl.querySelectorAll('.file-tree-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const path = item.dataset.path;
+ if (item.dataset.type === 'directory') {
+ this._openFileBrowserSearchDirectory({ ...searchContext, path });
+ } else {
+ this.openFilePreview(path, ownerSessionId);
+ }
+ });
+ });
+
+ const statusEl = this.$('fileBrowserStatus');
+ if (statusEl) {
+ const count = data.matchCount === undefined ? matches.length : data.matchCount;
+ statusEl.textContent = `${count} ${count === 1 ? 'match' : 'matches'}${data.truncated ? ' (truncated)' : ''}`;
}
- this.renderFileBrowserTree();
+ },
+
+ _findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
+ if (!Array.isArray(nodes)) return null;
+ for (const node of nodes) {
+ if (!node || typeof node !== 'object') continue;
+ if (node.type === 'directory' && node.path === targetPath) {
+ return { target: node, ancestors: [...ancestors] };
+ }
+ if (node.type !== 'directory' || !Array.isArray(node.children)) continue;
+ const found = this._findFileBrowserDirectory(node.children, targetPath, [...ancestors, node.path]);
+ if (found) return found;
+ }
+ return null;
+ },
+
+ _isFileBrowserDirectoryContextCurrent(target) {
+ const state = this._ensureFileBrowserState();
+ const input = this.$?.('fileBrowserSearch');
+ const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
+ return (
+ target &&
+ state.ownerSessionId === target.ownerSessionId &&
+ this.activeSessionId === target.ownerSessionId &&
+ state.treeEpoch === target.treeEpoch &&
+ state.searchEpoch === target.searchEpoch &&
+ (this.fileBrowserShowHidden === true) === target.showHidden &&
+ state.filter === target.rawInput &&
+ currentInput === target.rawInput &&
+ currentInput.trim() === target.query &&
+ state.view === target.view &&
+ this.$?.('fileBrowserPanel')?.classList.contains('visible') === true
+ );
+ },
+
+ _promptFileBrowserDirectoryReload() {
+ this.showToast?.('Reload files before opening this folder', 'info');
+ },
+
+ _openFileBrowserSearchDirectory(target) {
+ if (!this._isFileBrowserDirectoryContextCurrent(target)) return;
+ const state = this._ensureFileBrowserState();
+ const normalState = state.normalState;
+ if (
+ !this._isFileBrowserNormalCompatible(
+ normalState,
+ target.ownerSessionId,
+ target.showHidden,
+ target.treeEpoch,
+ )
+ ) {
+ state.deferredDirectoryTarget = null;
+ this._promptFileBrowserDirectoryReload();
+ return;
+ }
+
+ if (normalState.phase === 'loading') {
+ state.deferredDirectoryTarget = { ...target };
+ return;
+ }
+
+ state.deferredDirectoryTarget = null;
+ if (normalState.phase !== 'ready') {
+ this._promptFileBrowserDirectoryReload();
+ return;
+ }
+
+ const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
+ if (!found) {
+ this._promptFileBrowserDirectoryReload();
+ return;
+ }
+ this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
+ },
+
+ _completeDeferredFileBrowserDirectory(normalState) {
+ const state = this._ensureFileBrowserState();
+ const target = state.deferredDirectoryTarget;
+ if (!target) return false;
+ if (!this._isFileBrowserDirectoryContextCurrent(target)) {
+ if (state.deferredDirectoryTarget === target) state.deferredDirectoryTarget = null;
+ return false;
+ }
+ if (
+ !this._isFileBrowserNormalCompatible(
+ normalState,
+ target.ownerSessionId,
+ target.showHidden,
+ target.treeEpoch,
+ ) ||
+ (normalState.phase !== 'ready' && normalState.phase !== 'error')
+ ) {
+ return false;
+ }
+
+ state.deferredDirectoryTarget = null;
+ if (normalState.phase === 'error') {
+ this._promptFileBrowserDirectoryReload();
+ return false;
+ }
+
+ const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
+ if (!found) {
+ this._promptFileBrowserDirectoryReload();
+ return false;
+ }
+ this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
+ return true;
+ },
+
+ _leaveFileBrowserSearchForDirectory(paths, normalState) {
+ const state = this._ensureFileBrowserState();
+ if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
+ clearTimeout(state.inFlight.timer);
+ }
+ state.searchEpoch++;
+ state.inFlight = null;
+ state.filter = '';
+ state.matches = [];
+ state.deferredDirectoryTarget = null;
+ state.view = 'normal';
+ this.fileBrowserFilter = '';
+
+ const input = this.$?.('fileBrowserSearch');
+ if (input) input.value = '';
+ this._syncFileBrowserExpandBtn();
+ for (const path of paths) {
+ if (typeof path === 'string') this.fileBrowserExpandedDirs?.add?.(path);
+ }
+ this.fileBrowserData = normalState.data;
+ this._renderFileBrowserNormalState(normalState);
},
expandAllDirectories(nodes) {
@@ -3151,6 +3779,10 @@ Object.assign(CodemanApp.prototype, {
},
toggleFileBrowserExpand() {
+ if (this._hasFileBrowserQuery()) {
+ this._syncFileBrowserExpandBtn();
+ return;
+ }
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
const btn = this.$('fileBrowserExpandBtn');
@@ -3165,14 +3797,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;
+ const expandBtn = this.$('fileBrowserExpandBtn');
+ if (expandBtn) expandBtn.innerHTML = '\u229E';
+ const searchInput = this.$('fileBrowserSearch');
+ if (searchInput) searchInput.value = '';
+ this._syncFileBrowserExpandBtn();
+
+ 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 โ
@@ -3203,6 +3849,7 @@ Object.assign(CodemanApp.prototype, {
closeFileBrowserPanel() {
const panel = this.$('fileBrowserPanel');
+ this._resetFileBrowserForHide();
if (panel) {
panel.classList.remove('visible');
// Reset position so it reopens at default location
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 9d9a42bd0..ab74c30bd 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -2832,7 +2832,8 @@ Object.assign(CodemanApp.prototype, {
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
- } else {
+ } else if (fileBrowserPanel.classList.contains('visible')) {
+ this._resetFileBrowserForHide?.();
fileBrowserPanel.classList.remove('visible');
}
}
diff --git a/test/file-browser-hidden.test.ts b/test/file-browser-hidden.test.ts
index f5817fc39..a867d6805 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
new file mode 100644
index 000000000..13e804b85
--- /dev/null
+++ b/test/file-browser-search.test.ts
@@ -0,0 +1,1972 @@
+/**
+ * @fileoverview File Viewer server-side search (COD-341).
+ *
+ * The browser module is loaded as the real CodemanApp mixin in a VM. The fake
+ * DOM intentionally implements only the element contract used by the File
+ * Viewer, while fetch responses and timers remain controllable so races can be
+ * exercised without jsdom.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
+const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
+const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
+const settingsJs = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
+
+type ClickHandler = () => void;
+
+interface FakeClassList {
+ add: (...names: string[]) => void;
+ remove: (...names: string[]) => void;
+ contains: (name: string) => boolean;
+ toggle: (name: string, force?: boolean) => boolean;
+}
+
+interface FakeRow {
+ dataset: Record;
+ addEventListener: (type: string, handler: ClickHandler) => void;
+ click: () => void;
+}
+
+interface FakeElement {
+ innerHTML: string;
+ textContent: string;
+ value: string;
+ disabled: boolean;
+ style: Record;
+ attrs: Record;
+ classList: FakeClassList;
+ setAttribute: (name: string, value: string) => void;
+ querySelector: (selector: string) => FakeElement | null;
+ querySelectorAll: (selector: string) => FakeRow[];
+}
+
+function decodeHtml(value: string): string {
+ return value
+ .replaceAll('"', '"')
+ .replaceAll(''', "'")
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('&', '&');
+}
+
+function escapeHtml(value: unknown): string {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function fakeElement(initialClasses: string[] = []): FakeElement {
+ const classes = new Set(initialClasses);
+ const attrs: Record = {};
+ const rows: FakeRow[] = [];
+ let html = '';
+
+ const classList: FakeClassList = {
+ add: (...names) => names.forEach((name) => classes.add(name)),
+ remove: (...names) => names.forEach((name) => classes.delete(name)),
+ contains: (name) => classes.has(name),
+ toggle(name, force) {
+ const on = force === undefined ? !classes.has(name) : force;
+ if (on) classes.add(name);
+ else classes.delete(name);
+ return on;
+ },
+ };
+
+ return {
+ get innerHTML() {
+ return html;
+ },
+ set innerHTML(value: string) {
+ html = value;
+ rows.length = 0;
+ const rowPattern = /]*)>/g;
+ for (const match of value.matchAll(rowPattern)) {
+ const attributes = match[1];
+ const dataset: Record
= {};
+ for (const attr of attributes.matchAll(/data-([a-z-]+)="([^"]*)"/g)) {
+ const key = attr[1].replace(/-([a-z])/g, (_whole, letter: string) => letter.toUpperCase());
+ dataset[key] = decodeHtml(attr[2]);
+ }
+ let clickHandler: ClickHandler | null = null;
+ rows.push({
+ dataset,
+ addEventListener(type, handler) {
+ if (type === 'click') clickHandler = handler;
+ },
+ click() {
+ clickHandler?.();
+ },
+ });
+ }
+ },
+ textContent: '',
+ value: '',
+ disabled: false,
+ style: {},
+ attrs,
+ classList,
+ setAttribute(name, value) {
+ attrs[name] = value;
+ },
+ querySelector() {
+ return null;
+ },
+ querySelectorAll(selector) {
+ return selector === '.file-tree-item' ? rows : [];
+ },
+ };
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+interface FakeResponse {
+ ok: boolean;
+ status: number;
+ json: () => Promise;
+}
+
+function response(body: unknown, ok = true): FakeResponse {
+ return { ok, status: ok ? 200 : 500, json: async () => body };
+}
+
+function loadPanel(options: { sessionId?: string | null; showHidden?: boolean } = {}) {
+ const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record;
+ const elements: Record = {
+ fileBrowserPanel: fakeElement(['visible']),
+ fileBrowserTree: fakeElement(),
+ fileBrowserStatus: fakeElement(),
+ fileBrowserSearch: fakeElement(),
+ fileBrowserExpandBtn: fakeElement(),
+ fileBrowserHiddenBtn: fakeElement(),
+ };
+ const pending: Array<{ url: string; reply: ReturnType> }> = [];
+ const context = vm.createContext({
+ CodemanApp,
+ console,
+ escapeHtml,
+ localStorage: { getItem: () => null, setItem: vi.fn() },
+ document: {
+ getElementById: (id: string) => elements[id] ?? null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ querySelector: vi.fn(),
+ },
+ window: { addEventListener: vi.fn() },
+ setTimeout,
+ clearTimeout,
+ fetch: (url: string) => {
+ const reply = deferred();
+ pending.push({ url, reply });
+ return reply.promise;
+ },
+ });
+ vm.runInContext(settingsJs, context, { filename: 'settings-ui.js' });
+ vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
+
+ const app = new CodemanApp() as Record;
+ app.$ = (id: string) => elements[id] ?? null;
+ app.activeSessionId = options.sessionId === undefined ? 'session/A' : options.sessionId;
+ app.fileBrowserData = null;
+ app.fileBrowserExpandedDirs = new Set();
+ app.fileBrowserFilter = '';
+ app.fileBrowserAllExpanded = false;
+ app.fileBrowserShowHidden = options.showHidden ?? false;
+ app.openFilePreview = vi.fn();
+ app.showToast = vi.fn();
+ app.loadAppSettingsFromStorage = () => ({ showFileBrowser: false });
+ app.getDefaultSettings = () => ({});
+ app.saveAppSettingsToStorage = vi.fn();
+
+ return { app, elements, pending };
+}
+
+function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {}) {
+ const elements: Record = {
+ fileBrowserPanel: fakeElement(['visible']),
+ fileBrowserTree: fakeElement(),
+ fileBrowserStatus: fakeElement(),
+ fileBrowserSearch: fakeElement(),
+ fileBrowserExpandBtn: fakeElement(),
+ fileBrowserHiddenBtn: fakeElement(),
+ };
+ const filePending: Array<{ url: string; reply: ReturnType> }> = [];
+ const idleCallbacks: Array<() => void> = [];
+ const context = vm.createContext({
+ console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
+ performance,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn(),
+ requestIdleCallback: (callback: () => void) => {
+ idleCallbacks.push(callback);
+ return idleCallbacks.length;
+ },
+ HTMLCanvasElement: class HTMLCanvasElement {},
+ WebSocket: { OPEN: 1 },
+ MobileDetection: { isTouchDevice: () => false },
+ localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ document: {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ getElementById: (id: string) => elements[id] ?? null,
+ querySelector: vi.fn(() => null),
+ },
+ window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
+ fetch: (url: string) => {
+ if (url.includes('/files?')) {
+ const reply = deferred();
+ filePending.push({ url, reply });
+ return reply.promise;
+ }
+ if (url.includes('/terminal?')) {
+ if (options.terminalFailure) return Promise.reject(new Error('terminal replay failed'));
+ return Promise.resolve({
+ ok: true,
+ headers: { get: () => '' },
+ json: async () => ({ data: { terminalBuffer: '', truncated: false, source: 'test' } }),
+ });
+ }
+ return Promise.reject(new Error(`Unexpected fetch: ${url}`));
+ },
+ });
+ vm.runInContext(`${constantsJs}\n${appJs}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`, context);
+
+ const CodemanApp = (context as { __CodemanApp: new () => unknown }).__CodemanApp;
+ const app = Object.create(CodemanApp.prototype) as Record;
+ const terminalBoundary = deferred();
+ let resizeCalls = 0;
+ app.activeSessionId = 'session/A';
+ app.detachedSessions = new Set();
+ app.isSoloWindow = false;
+ app._selectGeneration = 0;
+ app.sessions = new Map([
+ ['session/B', { id: 'session/B', name: 'B', pid: 1, status: 'idle', mode: 'shell', workingDir: '/tmp/B' }],
+ ]);
+ app.fileBrowserData = successfulTree('old-A.ts').data;
+ app.fileBrowserExpandedDirs = new Set();
+ app.fileBrowserFilter = '';
+ app.fileBrowserAllExpanded = false;
+ app.fileBrowserShowHidden = false;
+ app.fileBrowserDragListeners = null;
+ app.$ = (id: string) => elements[id] ?? null;
+ app._shouldFocusTerminalForTabSwitch = () => false;
+ app._setTerminalLoadState = vi.fn();
+ app._clearTerminalLoadState = vi.fn();
+ app._cleanupPreviousSession = vi.fn();
+ app._renderHistoryTruncationBanner = vi.fn();
+ app._updateSseSubscription = vi.fn();
+ app.hideWelcome = vi.fn();
+ app.markIdleAlertSeen = vi.fn();
+ app._updateActiveTabImmediate = vi.fn();
+ app.closeSessionSidebarOnHandheld = vi.fn();
+ app.renderSessionTabs = vi.fn();
+ app.updateAttachmentHistoryBadge = vi.fn();
+ app.attachmentHistoryDrawerOpen = false;
+ app._updateLocalEchoState = vi.fn();
+ app._flushedOffsets = new Map();
+ app._flushedTexts = new Map();
+ app._localEchoOverlay = null;
+ app._beginBufferLoad = vi.fn(() => 1);
+ app._isLoadingBuffer = false;
+ app.fitAddon = { fit: vi.fn() };
+ app.sendResize = vi.fn(() => {
+ resizeCalls++;
+ return resizeCalls === 1 ? terminalBoundary.promise : Promise.resolve(false);
+ });
+ app.terminalBufferCache = new Map();
+ app._xtermSnapshots = new Map();
+ app._fullHistoryLoaded = new Set();
+ app._resetTerminalForReplay = vi.fn();
+ app._connectWs = vi.fn();
+ app.scrollToLastNonEmptyLine = vi.fn();
+ app._recordTerminalLoadTiming = vi.fn();
+ app.respawnStatus = {};
+ app.respawnCountdownTimers = {};
+ app.hideRespawnBanner = vi.fn();
+ app.stopCountdownInterval = vi.fn();
+ app.renderRalphStatePanel = vi.fn();
+ app.updateCliInfoDisplay = vi.fn();
+ app.renderProjectInsightsPanel = vi.fn();
+ app.updateSubagentWindowVisibility = vi.fn();
+ app.loadAppSettingsFromStorage = () => ({ showFileBrowser: true });
+
+ const activateImplementation = app._activateFileBrowserSession.bind(app);
+ app._activateFileBrowserSession = vi.fn(activateImplementation);
+ const loadImplementation = app.loadFileBrowser.bind(app);
+ app.loadFileBrowser = vi.fn(loadImplementation);
+
+ return { app, elements, filePending, idleCallbacks, terminalBoundary };
+}
+
+async function startSearch(app: Record, query = 'widget') {
+ const input = app.$('fileBrowserSearch');
+ if (input) input.value = query;
+ app.filterFileBrowser(query);
+ await vi.advanceTimersByTimeAsync(250);
+}
+
+async function settleSearch(
+ app: Record,
+ pending: Array<{ url: string; reply: ReturnType> }>,
+ body: unknown,
+ ok = true
+) {
+ await startSearch(app);
+ pending[0].reply.resolve(response(body, ok));
+ await vi.advanceTimersByTimeAsync(0);
+}
+
+function successfulData(overrides: Record = {}) {
+ return {
+ success: true,
+ data: {
+ mode: 'search',
+ matches: [],
+ truncated: false,
+ matchCount: 0,
+ ...overrides,
+ },
+ };
+}
+
+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();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('debounces for 250ms and encodes the owner and exact trimmed query', async () => {
+ const { app, pending } = loadPanel();
+
+ app.filterFileBrowser('src & docs');
+ await vi.advanceTimersByTimeAsync(249);
+ expect(pending).toHaveLength(0);
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(pending).toHaveLength(1);
+ expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
+ });
+
+ it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
+ const { app, elements, pending } = loadPanel({ sessionId: null });
+ app.filterFileBrowser('before-session');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(0);
+
+ app.activeSessionId = 'session/B';
+ app.filterFileBrowser('after-session');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
+
+ pending[0].reply.resolve(response(successfulData()));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('No matches');
+
+ delete elements.fileBrowserPanel;
+ app.filterFileBrowser('without-panel');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(1);
+ });
+
+ it('does not schedule when the active session is missing or differs from the retained owner', async () => {
+ const { app, elements, pending } = loadPanel();
+ app._ensureFileBrowserState();
+
+ app.activeSessionId = null;
+ app.filterFileBrowser('missing-active');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(0);
+ expect(elements.fileBrowserTree.innerHTML).toBe('');
+ expect(app._fileBrowserState.inFlight).toBeNull();
+
+ app.activeSessionId = 'session/B';
+ app.filterFileBrowser('wrong-active');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(0);
+ expect(elements.fileBrowserTree.innerHTML).toBe('');
+ expect(app._fileBrowserState.inFlight).toBeNull();
+ });
+
+ it('cancels the prior debounce and clears a deferred directory target on every input', async () => {
+ const { app, pending } = loadPanel();
+ app._ensureFileBrowserState().deferredDirectoryTarget = {
+ ownerSessionId: 'session/A',
+ path: 'old-directory',
+ };
+
+ app.filterFileBrowser('first query');
+ await vi.advanceTimersByTimeAsync(200);
+ app._fileBrowserState.deferredDirectoryTarget = {
+ ownerSessionId: 'session/A',
+ path: 'another-directory',
+ };
+ app.filterFileBrowser('second query');
+
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ await vi.advanceTimersByTimeAsync(249);
+ expect(pending).toHaveLength(0);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(pending.map(({ url }) => url)).toEqual([
+ '/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
+ ]);
+ });
+
+ it('renders flat file and directory matches with captured-owner actions and status', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(
+ app,
+ pending,
+ successfulData({
+ matches: [
+ { name: 'widget.ts', path: 'src/widget.ts', type: 'file', size: 1536, extension: 'ts' },
+ { name: 'widgets', path: 'docs/widgets', type: 'directory' },
+ ],
+ matchCount: 2,
+ truncated: true,
+ })
+ );
+
+ expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-name">widget.ts');
+ expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-name directory">widgets');
+ expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-size">1.5 KB');
+ expect(elements.fileBrowserTree.innerHTML).toContain('๐');
+ expect(elements.fileBrowserTree.innerHTML).toContain('๐');
+ expect(elements.fileBrowserTree.innerHTML).toContain('data-path="src/widget.ts"');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('class="file-tree-path"');
+ expect(elements.fileBrowserTree.innerHTML).toContain(
+ 'href="/api/sessions/session%2FA/file-raw?path=src%2Fwidget.ts&download=true"'
+ );
+ expect(elements.fileBrowserStatus.textContent).toBe('2 matches (truncated)');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
+
+ app._openFileBrowserSearchDirectory = vi.fn();
+ app.activeSessionId = 'later-session';
+ const rows = elements.fileBrowserTree.querySelectorAll('.file-tree-item');
+ rows[0].click();
+ rows[1].click();
+ expect(app.openFilePreview).toHaveBeenCalledWith('src/widget.ts', 'session/A');
+ expect(app._openFileBrowserSearchDirectory).toHaveBeenCalledWith({
+ ownerSessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ searchEpoch: 1,
+ rawInput: 'widget',
+ query: 'widget',
+ path: 'docs/widgets',
+ view: 'search-results',
+ });
+ });
+
+ it('uses the match list length when matchCount is omitted and renders no matches', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(app, pending, successfulData({ matchCount: undefined }));
+
+ expect(elements.fileBrowserTree.innerHTML).toContain('No matches');
+ expect(elements.fileBrowserStatus.textContent).toBe('0 matches');
+ });
+
+ it('rejects deferred results after any captured search context changes', async () => {
+ const scenarios: Array<{
+ name: string;
+ mutate: (app: Record, elements: Record) => void;
+ }> = [
+ {
+ name: 'raw input',
+ mutate: (app) => {
+ app._fileBrowserState.filter = 'changed raw input';
+ },
+ },
+ {
+ name: 'search epoch',
+ mutate: (app) => {
+ app._fileBrowserState.searchEpoch++;
+ },
+ },
+ {
+ name: 'owner',
+ mutate: (app) => {
+ app._fileBrowserState.ownerSessionId = 'another-owner';
+ },
+ },
+ {
+ name: 'active session',
+ mutate: (app) => {
+ app.activeSessionId = 'another-active-session';
+ },
+ },
+ {
+ name: 'hidden preference',
+ mutate: (app) => {
+ app.fileBrowserShowHidden = true;
+ },
+ },
+ {
+ name: 'panel visibility',
+ mutate: (_app, elements) => {
+ elements.fileBrowserPanel.classList.remove('visible');
+ },
+ },
+ ];
+
+ for (const scenario of scenarios) {
+ const { app, elements, pending } = loadPanel();
+ await startSearch(app);
+ expect(app._fileBrowserState.inFlight.treeEpoch, scenario.name).toBe(0);
+ scenario.mutate(app, elements);
+
+ pending[0].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: `late-${scenario.name}.ts`, path: 'late.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(elements.fileBrowserTree.innerHTML, scenario.name).toContain('Searching');
+ expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`);
+ expect(app._fileBrowserState.inFlight, scenario.name).toBeNull();
+ }
+ });
+
+ it('keeps query B rendered when its response beats an already-launched query A', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.filterFileBrowser('query A');
+ await vi.advanceTimersByTimeAsync(250);
+ app.filterFileBrowser('query B');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(2);
+
+ pending[1].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'result-B.ts', path: 'result-B.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('result-B.ts');
+
+ pending[0].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'result-A.ts', path: 'result-A.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(elements.fileBrowserTree.innerHTML).toContain('result-B.ts');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('result-A.ts');
+ expect(app._fileBrowserState.matches[0].name).toBe('result-B.ts');
+ });
+
+ it('clears a blank query and restores the compatible cached tree immediately without fetching', () => {
+ const { app, elements, pending } = loadPanel();
+ app.fileBrowserData = {
+ tree: [{ name: 'cached.ts', path: 'src/cached.ts', type: 'file', size: 4, extension: 'ts' }],
+ totalFiles: 1,
+ totalDirectories: 0,
+ truncated: false,
+ };
+
+ app.filterFileBrowser('cache');
+ expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
+ app.filterFileBrowser(' ');
+
+ expect(pending).toHaveLength(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('cached.ts');
+ expect(elements.fileBrowserStatus.textContent).toBe('1 files, 0 dirs');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ });
+
+ it('binds normal-tree previews and encoded downloads to the captured owner', () => {
+ const { app, elements } = loadPanel();
+ app.fileBrowserData = {
+ tree: [
+ {
+ name: 'normal & safe.ts',
+ path: 'src/normal & safe.ts',
+ type: 'file',
+ size: 7,
+ extension: 'ts',
+ },
+ ],
+ totalFiles: 1,
+ totalDirectories: 0,
+ truncated: false,
+ };
+ app.renderFileBrowserTree();
+
+ expect(elements.fileBrowserTree.innerHTML).toContain(
+ 'href="/api/sessions/session%2FA/file-raw?path=src%2Fnormal%20%26%20safe.ts&download=true"'
+ );
+ app.activeSessionId = 'later-session';
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+ expect(app.openFilePreview).toHaveBeenCalledWith('src/normal & safe.ts', 'session/A');
+ });
+
+ it('accepts 256 trimmed characters', async () => {
+ const { app, pending } = loadPanel();
+ app.filterFileBrowser(` ${'x'.repeat(256)} `);
+
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(pending).toHaveLength(1);
+ expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
+ });
+
+ it('rejects 257 trimmed characters without a request', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.filterFileBrowser('x'.repeat(257));
+
+ await vi.advanceTimersByTimeAsync(1_000);
+
+ expect(pending).toHaveLength(0);
+ expect(elements.fileBrowserStatus.textContent).toBe('Search queries are limited to 256 characters');
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(app._fileBrowserState.view).toBe('query-error');
+ });
+
+ it('fails closed on a non-2xx response', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(app, pending, { success: true }, false);
+
+ expect(elements.fileBrowserTree.innerHTML).toBe('Search failed
');
+ expect(elements.fileBrowserStatus.textContent).toBe('Search failed');
+ expect(app._fileBrowserState.view).toBe('search-error');
+ });
+
+ it('fails closed on an unsuccessful envelope', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(app, pending, { success: false, error: 'server detail' });
+
+ expect(elements.fileBrowserTree.innerHTML).toBe('Search failed
');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('server detail');
+ });
+
+ it.each([
+ ['mode is not search', successfulData({ mode: 'tree' })],
+ ['matches is not an array', successfulData({ matches: {} })],
+ ['a match name is not a string', successfulData({ matches: [{ name: 1, path: 'x', type: 'file' }] })],
+ ['a match path is not a string', successfulData({ matches: [{ name: 'x', path: null, type: 'file' }] })],
+ ['a match type is unknown', successfulData({ matches: [{ name: 'x', path: 'x', type: 'symlink' }] })],
+ [
+ 'a supplied size is not a number',
+ successfulData({ matches: [{ name: 'x', path: 'x', type: 'file', size: '1' }] }),
+ ],
+ [
+ 'a supplied extension is not a string',
+ successfulData({ matches: [{ name: 'x', path: 'x', type: 'file', extension: 1 }] }),
+ ],
+ ['truncated is not boolean', successfulData({ truncated: 'false' })],
+ ['matchCount is negative', successfulData({ matchCount: -1 })],
+ ['matchCount is not finite', successfulData({ matchCount: Number.POSITIVE_INFINITY })],
+ ])('rejects a malformed successful envelope when %s', async (_case, body) => {
+ const { app, elements, pending } = loadPanel();
+ app.fileBrowserData = {
+ tree: [{ name: 'cached-safe.ts', path: 'cached-safe.ts', type: 'file' }],
+ totalFiles: 1,
+ totalDirectories: 0,
+ truncated: false,
+ };
+ const malformed = structuredClone(body);
+ if (_case === 'matchCount is not finite') {
+ (malformed as { data: { matchCount: number } }).data.matchCount = Number.POSITIVE_INFINITY;
+ }
+ await settleSearch(app, pending, malformed);
+
+ expect(elements.fileBrowserTree.innerHTML).toBe('Search failed
');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-safe.ts');
+ expect(app.fileBrowserData.tree[0].name).toBe('cached-safe.ts');
+ });
+
+ it('escapes metacharacters in displayed values, attributes, and links', async () => {
+ const owner = 'session/"<&\'';
+ const path = 'src/"<&\'/evil.ts';
+ const name = '
&\'';
+ const { app, elements, pending } = loadPanel({ sessionId: owner });
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name, path, type: 'file', extension: 'ts' }], matchCount: 1 })
+ );
+
+ const html = elements.fileBrowserTree.innerHTML;
+ expect(html).toContain('<img src=x onerror="boom"> &'');
+ expect(html).toContain('src/"<&'/evil.ts');
+ expect(html).toContain('data-path="src/"<&'/evil.ts"');
+ expect(html).toContain(
+ 'href="/api/sessions/session%2F%22%3C%26'/file-raw?path=src%2F%22%3C%26'%2Fevil.ts&download=true"'
+ );
+ expect(html).not.toContain('
{
+ it('owns normal loading immediately and reruns an exact valid query only after the replacement tree succeeds', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.fileBrowserData = successfulTree('old-preference.ts').data;
+ await settleSearch(
+ app,
+ pending,
+ successfulData({
+ matches: [{ name: 'old-result.ts', path: 'old-result.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ );
+ elements.fileBrowserSearch.value = ' widget ';
+ app._fileBrowserState.filter = ' widget ';
+ app.fileBrowserFilter = ' widget ';
+ const beforeSearchEpoch = app._fileBrowserState.searchEpoch;
+ const beforeTreeEpoch = app._fileBrowserState.treeEpoch;
+
+ const toggle = app.toggleFileBrowserHidden();
+
+ expect(app.fileBrowserShowHidden).toBe(true);
+ expect(elements.fileBrowserHiddenBtn.attrs['aria-pressed']).toBe('true');
+ expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1);
+ expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1);
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(app._fileBrowserState.normalState).toMatchObject({
+ sessionId: 'session/A',
+ showHidden: true,
+ treeEpoch: beforeTreeEpoch + 1,
+ phase: 'loading',
+ });
+ expect(app.fileBrowserData).toBeNull();
+ expect(elements.fileBrowserSearch.value).toBe(' widget ');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
+ expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
+ expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
+
+ pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
+ await toggle;
+
+ expect(app._fileBrowserState.view).toBe('search-pending');
+ expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
+ pending[2].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'new-result.ts', path: 'new-result.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('new-result.ts');
+ });
+
+ it('lets input during the reload own the UI and invalidates the captured continuation', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
+ );
+
+ const toggle = app.toggleFileBrowserHidden();
+ await startSearch(app, 'new query');
+ expect(pending[2].url).toContain('showHidden=true&q=new%20query');
+ pending[2].reply.resolve(
+ response(
+ successfulData({ matches: [{ name: 'new-query.ts', path: 'new-query.ts', type: 'file' }], matchCount: 1 })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('new-query.ts');
+
+ pending[1].reply.resolve(response(successfulTree('behind-new-query.ts')));
+ await toggle;
+
+ expect(app._fileBrowserState.normalState.phase).toBe('ready');
+ expect(elements.fileBrowserTree.innerHTML).toContain('new-query.ts');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-new-query.ts');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(3);
+ });
+
+ it.each([
+ ['tree epoch', (app: Record) => app._fileBrowserState.treeEpoch++],
+ ['search epoch', (app: Record) => app._fileBrowserState.searchEpoch++],
+ ['owner', (app: Record) => (app._fileBrowserState.ownerSessionId = 'session/B')],
+ ['active session', (app: Record) => (app.activeSessionId = 'session/B')],
+ ['hidden preference', (app: Record) => (app.fileBrowserShowHidden = false)],
+ ['raw filter', (app: Record) => (app._fileBrowserState.filter = 'changed')],
+ [
+ 'raw input',
+ (_app: Record, elements: Record) =>
+ (elements.fileBrowserSearch.value = 'changed'),
+ ],
+ [
+ 'panel visibility',
+ (_app: Record, elements: Record) =>
+ elements.fileBrowserPanel.classList.remove('visible'),
+ ],
+ ])('does not rerun a captured query after its %s context changes', async (_case, mutate) => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(app, pending, successfulData());
+ const toggle = app.toggleFileBrowserHidden();
+
+ mutate(app, elements);
+ pending[1].reply.resolve(response(successfulTree('replacement.ts')));
+ await toggle;
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(pending).toHaveLength(2);
+ });
+
+ it('invalidates the first load and continuation when two toggles happen rapidly', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
+ );
+
+ const firstToggle = app.toggleFileBrowserHidden();
+ const secondToggle = app.toggleFileBrowserHidden();
+ expect(pending.slice(1).map(({ url }) => url)).toEqual([
+ '/api/sessions/session%2FA/files?depth=5&showHidden=true',
+ '/api/sessions/session%2FA/files?depth=5&showHidden=false',
+ ]);
+
+ pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
+ await firstToggle;
+ expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-hidden.ts');
+
+ pending[2].reply.resolve(response(successfulTree('fresh-visible.ts')));
+ await secondToggle;
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(pending).toHaveLength(4);
+ expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
+ expect(app.fileBrowserShowHidden).toBe(false);
+ });
+
+ it('stores and escapes a tree failure, keeps the new preference, and exposes the error on clear', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
+ );
+
+ const toggle = app.toggleFileBrowserHidden();
+ pending[1].reply.reject(new Error('
'));
+ await toggle;
+
+ expect(app.fileBrowserShowHidden).toBe(true);
+ expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', showHidden: true });
+ expect(app._fileBrowserState.normalState.error).toBe('
');
+ expect(elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('
{
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
+ );
+
+ const toggle = app.toggleFileBrowserHidden();
+ await startSearch(app, 'new query');
+ pending[2].reply.resolve(
+ response(successfulData({ matches: [{ name: 'newer.ts', path: 'newer.ts', type: 'file' }], matchCount: 1 }))
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ pending[1].reply.reject(new Error('new preference failed'));
+ await toggle;
+
+ expect(elements.fileBrowserTree.innerHTML).toContain('newer.ts');
+ expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', error: 'new preference failed' });
+ elements.fileBrowserSearch.value = '';
+ app.filterFileBrowser('');
+ expect(elements.fileBrowserTree.innerHTML).toContain('new preference failed');
+ });
+
+ it.each([
+ ['ready', false],
+ ['error', true],
+ ])(
+ 'reloads behind an unchanged overlength query-error and reveals the new normal %s state on clear',
+ async (_case, fail) => {
+ const { app, elements, pending } = loadPanel();
+ const query = 'x'.repeat(257);
+ elements.fileBrowserSearch.value = query;
+ app.filterFileBrowser(query);
+ const message = elements.fileBrowserTree.innerHTML;
+ const beforeSearchEpoch = app._fileBrowserState.searchEpoch;
+ const beforeTreeEpoch = app._fileBrowserState.treeEpoch;
+
+ const toggle = app.toggleFileBrowserHidden();
+
+ expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1);
+ expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1);
+ expect(app._fileBrowserState.view).toBe('query-error');
+ expect(elements.fileBrowserTree.innerHTML).toBe(message);
+ expect(pending).toHaveLength(1);
+ expect(pending[0].url).not.toContain('&q=');
+ if (fail) pending[0].reply.reject(new Error('hidden tree failed'));
+ else pending[0].reply.resolve(response(successfulTree('hidden-ready.ts')));
+ await toggle;
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(elements.fileBrowserTree.innerHTML).toBe(message);
+ expect(pending).toHaveLength(1);
+ elements.fileBrowserSearch.value = '';
+ app.filterFileBrowser('');
+ if (fail) expect(elements.fileBrowserTree.innerHTML).toContain('hidden tree failed');
+ else expect(elements.fileBrowserTree.innerHTML).toContain('hidden-ready.ts');
+ }
+ );
+
+ it('recovers a failed hidden transition through refresh without reverting the preference', async () => {
+ const { app, elements, pending } = loadPanel();
+ await settleSearch(app, pending, successfulData());
+ const toggle = app.toggleFileBrowserHidden();
+ pending[1].reply.reject(new Error('toggle failed'));
+ await toggle;
+
+ const refresh = app.refreshFileBrowser();
+ expect(app.fileBrowserShowHidden).toBe(true);
+ expect(pending[2].url).toContain('showHidden=true');
+ pending[2].reply.resolve(response(successfulTree('refresh-recovered.ts')));
+ await refresh;
+ expect(elements.fileBrowserTree.innerHTML).toContain('refresh-recovered.ts');
+ });
+
+ it('drops the prior-preference normal cache safely when the tree surface is missing', async () => {
+ const { app, elements } = loadPanel();
+ app.fileBrowserData = successfulTree('old-preference.ts').data;
+ app._ensureFileBrowserState();
+ delete elements.fileBrowserTree;
+
+ await expect(app.toggleFileBrowserHidden()).resolves.toBeUndefined();
+
+ expect(app.fileBrowserShowHidden).toBe(true);
+ expect(app.fileBrowserData).toBeNull();
+ expect(app._fileBrowserState.normalState).toBeNull();
+ });
+ });
+
+ describe('Expand and Collapse gating', () => {
+ it.each(['search-pending', 'search-results', 'search-error', 'query-error', 'normal'])(
+ 'disables the Expand control for trimmed input while the %s view owns the panel',
+ (view) => {
+ const { app, elements } = loadPanel();
+ const state = app._ensureFileBrowserState();
+ state.view = view;
+ state.filter = ' query ';
+ elements.fileBrowserSearch.value = ' query ';
+
+ app._syncFileBrowserExpandBtn();
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
+
+ state.filter = ' ';
+ elements.fileBrowserSearch.value = ' ';
+ app._syncFileBrowserExpandBtn();
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ }
+ );
+
+ it('guards Expand and Collapse without repainting or changing expansion state during a query', () => {
+ const { app, elements } = loadPanel();
+ app.fileBrowserData = successfulTree('tree.ts').data;
+ app._ensureFileBrowserState().filter = 'query';
+ elements.fileBrowserSearch.value = 'query';
+ elements.fileBrowserTree.innerHTML = 'search result sentinel
';
+ elements.fileBrowserExpandBtn.innerHTML = '\u229E';
+ const render = vi.spyOn(app, 'renderFileBrowserTree');
+
+ app.toggleFileBrowserExpand();
+
+ expect(app.fileBrowserAllExpanded).toBe(false);
+ expect(app.fileBrowserExpandedDirs.size).toBe(0);
+ expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
+ expect(elements.fileBrowserTree.innerHTML).toBe('search result sentinel
');
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ it('restores the control without changing its expansion state or icon when a search is cleared', () => {
+ const { app, elements } = loadPanel();
+ app.fileBrowserData = successfulTree('tree.ts').data;
+ app.fileBrowserAllExpanded = true;
+ app.fileBrowserExpandedDirs.add('existing');
+ elements.fileBrowserExpandBtn.innerHTML = '\u229F';
+ elements.fileBrowserSearch.value = 'query';
+ app.filterFileBrowser('query');
+
+ elements.fileBrowserSearch.value = '';
+ app.filterFileBrowser('');
+
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229F');
+ expect(app.fileBrowserAllExpanded).toBe(true);
+ expect(app.fileBrowserExpandedDirs.has('existing')).toBe(true);
+ });
+ });
+
+ describe('search directory navigation', () => {
+ const unixTree = [
+ {
+ name: 'src',
+ path: 'src',
+ type: 'directory',
+ children: [
+ {
+ name: 'components',
+ path: 'src/components',
+ type: 'directory',
+ children: [
+ {
+ name: 'widgets',
+ path: 'src/components/widgets',
+ type: 'directory',
+ children: [{ name: 'index.ts', path: 'src/components/widgets/index.ts', type: 'file' }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ it('finds a directory and its object-derived ancestors without parsing separators, including Windows paths', () => {
+ const { app } = loadPanel();
+ const windowsTree = [
+ {
+ name: 'src',
+ path: 'C:\\repo\\src',
+ type: 'directory',
+ children: [
+ {
+ name: 'widgets',
+ path: 'C:\\repo\\src\\widgets',
+ type: 'directory',
+ children: [],
+ },
+ ],
+ },
+ ];
+
+ const found = app._findFileBrowserDirectory(windowsTree, 'C:\\repo\\src\\widgets');
+
+ expect(found.target.path).toBe('C:\\repo\\src\\widgets');
+ expect(found.ancestors).toEqual(['C:\\repo\\src']);
+ });
+
+ it('leaves search explicitly for a ready directory and resets every search field without synthetic input', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.fileBrowserData = successfulTree('unused', {
+ tree: unixTree,
+ totalFiles: 1,
+ totalDirectories: 3,
+ }).data;
+ app.fileBrowserAllExpanded = true;
+ app.fileBrowserExpandedDirs.add('already-open');
+ elements.fileBrowserExpandBtn.innerHTML = '\u229F';
+ await settleSearch(
+ app,
+ pending,
+ successfulData({
+ matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
+ matchCount: 1,
+ })
+ );
+ const staleTimer = vi.fn();
+ app._fileBrowserState.inFlight = { timer: setTimeout(staleTimer, 10) };
+ app._fileBrowserState.deferredDirectoryTarget = { stale: true };
+ const beforeEpoch = app._fileBrowserState.searchEpoch;
+ const filter = vi.spyOn(app, 'filterFileBrowser');
+
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+ await vi.advanceTimersByTimeAsync(20);
+
+ expect(app._fileBrowserState.searchEpoch).toBe(beforeEpoch + 1);
+ expect(app._fileBrowserState.inFlight).toBeNull();
+ expect(app._fileBrowserState.filter).toBe('');
+ expect(app.fileBrowserFilter).toBe('');
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(elements.fileBrowserSearch.value).toBe('');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229F');
+ expect(app.fileBrowserAllExpanded).toBe(true);
+ expect([...app.fileBrowserExpandedDirs]).toEqual([
+ 'already-open',
+ 'src',
+ 'src/components',
+ 'src/components/widgets',
+ ]);
+ expect(app.fileBrowserData).toBe(app._fileBrowserState.normalState.data);
+ expect(elements.fileBrowserTree.innerHTML).toContain('index.ts');
+ expect(staleTimer).not.toHaveBeenCalled();
+ expect(filter).not.toHaveBeenCalled();
+ });
+
+ it('defers a directory transfer during the exact normal load while keeping results visible', async () => {
+ const { app, elements, pending } = loadPanel();
+ const treeLoad = app.loadFileBrowser('session/A');
+ await startSearch(app);
+ pending[1].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+
+ expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
+ expect(app._fileBrowserState.view).toBe('search-results');
+ expect(app._fileBrowserState.deferredDirectoryTarget).toEqual({
+ ownerSessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ searchEpoch: 1,
+ rawInput: 'widget',
+ query: 'widget',
+ path: 'src/components/widgets',
+ view: 'search-results',
+ });
+ const renderNormalTree = vi.spyOn(app, 'renderFileBrowserTree');
+
+ pending[0].reply.resolve(
+ response(successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }))
+ );
+ await treeLoad;
+
+ expect(renderNormalTree).toHaveBeenCalledTimes(1);
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(elements.fileBrowserTree.innerHTML).toContain('index.ts');
+ expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(4);
+ expect(app.fileBrowserExpandedDirs.has('src/components/widgets')).toBe(true);
+ });
+
+ it('cancels a deferred directory when a new query arrives', async () => {
+ const { app, elements, pending } = loadPanel();
+ const treeLoad = app.loadFileBrowser('session/A');
+ await startSearch(app);
+ pending[1].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+ expect(app._fileBrowserState.deferredDirectoryTarget).not.toBeNull();
+
+ elements.fileBrowserSearch.value = 'new query';
+ app.filterFileBrowser('new query');
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ pending[0].reply.resolve(
+ response(successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }))
+ );
+ await treeLoad;
+
+ expect(app._fileBrowserState.view).toBe('search-pending');
+ expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
+ });
+
+ it('retains results, clears the deferred target, and toasts when the exact normal load fails', async () => {
+ const { app, elements, pending } = loadPanel();
+ const treeLoad = app.loadFileBrowser('session/A');
+ await startSearch(app);
+ pending[1].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+
+ pending[0].reply.reject(new Error('tree failed'));
+ await treeLoad;
+
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ expect(app._fileBrowserState.view).toBe('search-results');
+ expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
+ expect(app.showToast).toHaveBeenCalledWith('Reload files before opening this folder', 'info');
+ expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', error: 'tree failed' });
+
+ elements.fileBrowserSearch.value = '';
+ app.filterFileBrowser('');
+ expect(elements.fileBrowserTree.innerHTML).toContain('tree failed');
+ });
+
+ it.each([
+ ['error', { phase: 'error', error: 'cached failure' }],
+ [
+ 'ready cache missing the directory',
+ {
+ phase: 'ready',
+ data: successfulTree('other.ts').data,
+ },
+ ],
+ ])('retains search results and prompts for reload for a compatible %s normal state', async (_case, normal) => {
+ const { app, elements, pending } = loadPanel();
+ const state = app._ensureFileBrowserState();
+ state.normalState = {
+ sessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ ...normal,
+ };
+ await settleSearch(
+ app,
+ pending,
+ successfulData({ matches: [{ name: 'missing', path: 'missing', type: 'directory' }], matchCount: 1 })
+ );
+
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+
+ expect(app._fileBrowserState.view).toBe('search-results');
+ expect(app._fileBrowserState.filter).toBe('widget');
+ expect(elements.fileBrowserTree.innerHTML).toContain('missing');
+ expect(app.showToast).toHaveBeenCalledWith('Reload files before opening this folder', 'info');
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ });
+
+ it.each(['session', 'hidden preference', 'search epoch', 'tree epoch'])(
+ 'does not complete a deferred transfer after a new %s invalidates its binding',
+ async (scenario) => {
+ const { app, elements, pending } = loadPanel();
+ app.loadFileBrowser('session/A');
+ await startSearch(app);
+ pending[1].reply.resolve(
+ response(
+ successfulData({
+ matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
+ matchCount: 1,
+ })
+ )
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
+ const ready = {
+ sessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ phase: 'ready',
+ data: successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }).data,
+ };
+
+ if (scenario === 'session') {
+ app.activeSessionId = 'session/B';
+ app._fileBrowserState.ownerSessionId = 'session/B';
+ } else if (scenario === 'hidden preference') {
+ app.fileBrowserShowHidden = true;
+ } else if (scenario === 'search epoch') {
+ app._fileBrowserState.searchEpoch++;
+ } else {
+ app._fileBrowserState.treeEpoch++;
+ }
+ app._completeDeferredFileBrowserDirectory(ready);
+
+ expect(app._fileBrowserState.view).toBe('search-results');
+ expect(elements.fileBrowserSearch.value).toBe('widget');
+ expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
+ expect(app.fileBrowserExpandedDirs.size).toBe(0);
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ }
+ );
+
+ it('does not clear a newer deferred replacement while compare-and-clearing an obsolete binding', () => {
+ const { app } = loadPanel();
+ const state = app._ensureFileBrowserState();
+ const obsolete = {
+ ownerSessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ searchEpoch: 0,
+ rawInput: 'old',
+ query: 'old',
+ path: 'old/path',
+ view: 'search-results',
+ };
+ const replacement = { ...obsolete, searchEpoch: 1, rawInput: 'new', query: 'new', path: 'new/path' };
+ state.deferredDirectoryTarget = obsolete;
+ vi.spyOn(app, '_isFileBrowserDirectoryContextCurrent').mockImplementationOnce((target) => {
+ expect(target).toBe(obsolete);
+ state.deferredDirectoryTarget = replacement;
+ return false;
+ });
+
+ app._completeDeferredFileBrowserDirectory({
+ sessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ phase: 'ready',
+ data: successfulTree('ready.ts').data,
+ });
+
+ expect(state.deferredDirectoryTarget).toBe(replacement);
+ });
+ });
+
+ describe('normal tree loads', () => {
+ 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);
+ });
+ });
+
+ describe('session ownership lifecycle', () => {
+ it('activates the real selectSession owner before any terminal await can fail and retains the idle load', () => {
+ const selectStart = appJs.indexOf('async selectSession(sessionId, options = {})');
+ const activeAssignment = appJs.indexOf('this.activeSessionId = sessionId;', selectStart);
+ const activation = appJs.indexOf('this._activateFileBrowserSession?.(sessionId);', activeAssignment);
+ const firstTerminalAwait = appJs.indexOf('await ', activeAssignment);
+ const retainedIdleLoad = appJs.indexOf('this.loadFileBrowser(sessionId);', firstTerminalAwait);
+
+ expect(selectStart).toBeGreaterThanOrEqual(0);
+ expect(appJs.slice(activeAssignment, activation + 53)).toMatch(
+ /this\.activeSessionId = sessionId;\s*this\._activateFileBrowserSession\?\.\(sessionId\);/
+ );
+ expect(activeAssignment).toBeLessThan(activation);
+ expect(activation).toBeLessThan(firstTerminalAwait);
+ expect(firstTerminalAwait).toBeLessThan(retainedIdleLoad);
+ });
+
+ it('executes real selectSession activation before a pending terminal boundary and keeps the B load on failure', async () => {
+ const { app, filePending, terminalBoundary } = loadRealSelectSessionHarness({ terminalFailure: true });
+
+ const selection = app.selectSession('session/B');
+ const immediateState = {
+ activationCalls: app._activateFileBrowserSession.mock.calls.length,
+ activeSessionId: app.activeSessionId,
+ ownerSessionId: app._fileBrowserState?.ownerSessionId,
+ resizeCalls: app.sendResize.mock.calls.length,
+ fileUrls: filePending.map(({ url }) => url),
+ };
+
+ terminalBoundary.reject(new Error('resize failed'));
+ await selection;
+
+ expect(immediateState.activationCalls).toBe(1);
+ expect(app._activateFileBrowserSession).toHaveBeenCalledWith('session/B');
+ expect(immediateState.activeSessionId).toBe('session/B');
+ expect(immediateState.ownerSessionId).toBe('session/B');
+ expect(immediateState.resizeCalls).toBe(1);
+ expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
+
+ expect(filePending).toHaveLength(1);
+ expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
+ filePending[0].reply.resolve(response(successfulTree('B-after-terminal-failure.ts')));
+ await app._fileBrowserState.treeInFlight.promise;
+ expect(app.fileBrowserData.tree[0].name).toBe('B-after-terminal-failure.ts');
+ });
+
+ it.each([
+ ['in-flight request', false],
+ ['settled ready state', true],
+ ])('the real selectSession idle callback reuses the immediate B %s', async (_case, settleBeforeIdle) => {
+ const { app, elements, filePending, idleCallbacks, terminalBoundary } = loadRealSelectSessionHarness();
+
+ const selection = app.selectSession('session/B');
+ const immediateActivationCalls = app._activateFileBrowserSession.mock.calls.length;
+ const immediateLoadCalls = app.loadFileBrowser.mock.calls.length;
+ const immediateLoad = app.loadFileBrowser.mock.results[0]?.value;
+ const immediateRequestCount = filePending.length;
+
+ if (settleBeforeIdle && filePending[0]) {
+ filePending[0].reply.resolve(response(successfulTree('ready-B.ts')));
+ await immediateLoad;
+ expect(elements.fileBrowserTree.innerHTML).toContain('ready-B.ts');
+ }
+
+ terminalBoundary.resolve(false);
+ await selection;
+ expect(idleCallbacks).toHaveLength(1);
+ idleCallbacks[0]();
+
+ expect(immediateActivationCalls).toBe(1);
+ expect(immediateLoadCalls).toBe(1);
+ expect(immediateRequestCount).toBe(1);
+ expect(app.loadFileBrowser).toHaveBeenCalledTimes(2);
+ expect(app.loadFileBrowser).toHaveBeenLastCalledWith('session/B');
+ expect(filePending).toHaveLength(1);
+ const idleLoad = app.loadFileBrowser.mock.results[1].value;
+ if (settleBeforeIdle) {
+ await idleLoad;
+ expect(app._fileBrowserState.normalState.phase).toBe('ready');
+ } else {
+ expect(idleLoad).toBe(immediateLoad);
+ filePending[0].reply.resolve(response(successfulTree('in-flight-B.ts')));
+ await idleLoad;
+ }
+ });
+
+ it('synchronously resets A state, owns B, and starts one visible B tree load', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.fileBrowserData = successfulTree('old-A.ts').data;
+ app.renderFileBrowserTree('session/A');
+ app.filterFileBrowser('old query');
+ app.fileBrowserExpandedDirs.add('src');
+ app.fileBrowserAllExpanded = true;
+ app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' };
+ const searchEpoch = app._fileBrowserState.searchEpoch;
+ const treeEpoch = app._fileBrowserState.treeEpoch;
+
+ app.activeSessionId = 'session/B';
+ app._activateFileBrowserSession('session/B');
+
+ expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
+ expect(app._fileBrowserState.searchEpoch).toBe(searchEpoch + 1);
+ expect(app._fileBrowserState.treeEpoch).toBe(treeEpoch + 1);
+ expect(app._fileBrowserState.inFlight).toBeNull();
+ expect(app._fileBrowserState.normalState.phase).toBe('loading');
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ expect(app._fileBrowserState.filter).toBe('');
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(app.fileBrowserData).toBeNull();
+ expect(app.fileBrowserFilter).toBe('');
+ expect(app.fileBrowserExpandedDirs.size).toBe(0);
+ expect(app.fileBrowserAllExpanded).toBe(false);
+ expect(elements.fileBrowserSearch.value).toBe('');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
+ expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
+ expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
+ expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
+
+ await vi.advanceTimersByTimeAsync(250);
+ expect(pending).toHaveLength(1);
+ const activatedLoad = app._fileBrowserState.treeInFlight.promise;
+ const retainedIdleLoad = app.loadFileBrowser('session/B');
+ expect(retainedIdleLoad).toBe(activatedLoad);
+ expect(pending).toHaveLength(1);
+
+ pending[0].reply.resolve(response(successfulTree('current-B.ts')));
+ await retainedIdleLoad;
+ expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts');
+ });
+
+ it('does not load while hidden and tolerates missing session or elements', () => {
+ const hidden = loadPanel();
+ hidden.elements.fileBrowserPanel.classList.remove('visible');
+ hidden.app.activeSessionId = 'session/B';
+ expect(() => hidden.app._activateFileBrowserSession('session/B')).not.toThrow();
+ expect(hidden.pending).toHaveLength(0);
+ expect(hidden.app._fileBrowserState.ownerSessionId).toBe('session/B');
+ expect(hidden.app._fileBrowserState.normalState).toBeNull();
+ expect(hidden.elements.fileBrowserTree.innerHTML).toBe('');
+ expect(hidden.elements.fileBrowserStatus.textContent).toBe('');
+
+ const missing = loadPanel();
+ delete missing.elements.fileBrowserTree;
+ delete missing.elements.fileBrowserStatus;
+ delete missing.elements.fileBrowserSearch;
+ delete missing.elements.fileBrowserExpandBtn;
+ expect(() => missing.app._activateFileBrowserSession(null)).not.toThrow();
+ expect(() => missing.app._activateFileBrowserSession('session/B')).not.toThrow();
+ });
+
+ it('targets B when a query is entered before the activated B tree settles', async () => {
+ const { app, pending } = loadPanel();
+ app.activeSessionId = 'session/B';
+ app._activateFileBrowserSession('session/B');
+ app.filterFileBrowser('during load');
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(pending.map(({ url }) => url)).toEqual([
+ '/api/sessions/session%2FB/files?depth=5&showHidden=false',
+ '/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
+ ]);
+ expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
+ });
+
+ it('starts fresh A work on A to B to A and rejects the original A completion', async () => {
+ const { app, elements, pending } = loadPanel();
+ const firstA = app.loadFileBrowser('session/A');
+ app.activeSessionId = 'session/B';
+ app._activateFileBrowserSession('session/B');
+ const loadB = app._fileBrowserState.treeInFlight.promise;
+ app.activeSessionId = 'session/A';
+ app._activateFileBrowserSession('session/A');
+ const secondA = app._fileBrowserState.treeInFlight.promise;
+
+ expect(secondA).not.toBe(firstA);
+ expect(pending.map(({ url }) => url)).toEqual([
+ '/api/sessions/session%2FA/files?depth=5&showHidden=false',
+ '/api/sessions/session%2FB/files?depth=5&showHidden=false',
+ '/api/sessions/session%2FA/files?depth=5&showHidden=false',
+ ]);
+
+ pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+ await firstA;
+ expect(app._fileBrowserState.treeInFlight.promise).toBe(secondA);
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts');
+ pending[1].reply.reject(new Error('late B failure'));
+ await loadB;
+ expect(app._fileBrowserState.treeInFlight.promise).toBe(secondA);
+ pending[2].reply.resolve(response(successfulTree('fresh-A.ts')));
+ await secondA;
+ expect(elements.fileBrowserTree.innerHTML).toContain('fresh-A.ts');
+ });
+ });
+
+ describe('hide lifecycle', () => {
+ it('the real settings hide branch resets once and does not churn epochs while already hidden', () => {
+ const { app, elements } = loadPanel();
+ const state = app._ensureFileBrowserState();
+ const searchEpoch = state.searchEpoch;
+ const treeEpoch = state.treeEpoch;
+
+ app.applyMonitorVisibility();
+
+ expect(elements.fileBrowserPanel.classList.contains('visible')).toBe(false);
+ expect(state.searchEpoch).toBe(searchEpoch + 1);
+ expect(state.treeEpoch).toBe(treeEpoch + 1);
+ app.applyMonitorVisibility();
+ expect(state.searchEpoch).toBe(searchEpoch + 1);
+ expect(state.treeEpoch).toBe(treeEpoch + 1);
+ });
+
+ it('cancels an unlaunched debounce and clears all hide-reset controls and navigation state', async () => {
+ const { app, elements, pending } = loadPanel();
+ app.filterFileBrowser('not launched');
+ app.fileBrowserExpandedDirs.add('src');
+ app.fileBrowserAllExpanded = true;
+ app._fileBrowserState.matches = [{ name: 'old.ts' }];
+ app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' };
+ elements.fileBrowserSearch.value = 'not launched';
+ elements.fileBrowserExpandBtn.disabled = true;
+ elements.fileBrowserExpandBtn.innerHTML = '\u229F';
+
+ app.closeFileBrowserPanel();
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(pending).toHaveLength(0);
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(app._fileBrowserState.filter).toBe('');
+ expect(app.fileBrowserFilter).toBe('');
+ expect(app.fileBrowserExpandedDirs.size).toBe(0);
+ expect(app.fileBrowserAllExpanded).toBe(false);
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
+ expect(elements.fileBrowserSearch.value).toBe('');
+ expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
+ expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
+ });
+
+ it.each([
+ ['tree success', 'tree', false],
+ ['tree failure', 'tree', true],
+ ['search success', 'search', false],
+ ['search failure', 'search', true],
+ ])('explicit close blocks pending %s from updating state or UI', async (_name, kind, fail) => {
+ const { app, elements, pending } = loadPanel();
+ const operation = kind === 'tree' ? app.loadFileBrowser('session/A') : (await startSearch(app), undefined);
+
+ app.closeFileBrowserPanel();
+ expect(app._fileBrowserState.treeInFlight).toBeNull();
+ expect(app._fileBrowserState.inFlight).toBeNull();
+ expect(app._fileBrowserState.normalState).toBeNull();
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(elements.fileBrowserTree.innerHTML).toBe('');
+ expect(elements.fileBrowserStatus.textContent).toBe('');
+
+ if (fail) pending[0].reply.reject(new Error(`late ${kind} failure`));
+ else
+ pending[0].reply.resolve(
+ response(
+ kind === 'tree'
+ ? successfulTree('late.ts')
+ : successfulData({
+ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ if (operation) await operation;
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(app.fileBrowserData).toBeNull();
+ expect(app._fileBrowserState.normalState).toBeNull();
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(elements.fileBrowserTree.innerHTML).toBe('');
+ expect(elements.fileBrowserStatus.textContent).toBe('');
+ });
+
+ it.each([
+ ['tree success', 'tree', false],
+ ['tree failure', 'tree', true],
+ ['search success', 'search', false],
+ ['search failure', 'search', true],
+ ])('settings-driven hide blocks pending %s from updating state or UI', async (_name, kind, fail) => {
+ const { app, elements, pending } = loadPanel();
+ const operation = kind === 'tree' ? app.loadFileBrowser('session/A') : (await startSearch(app), undefined);
+
+ app.applyMonitorVisibility();
+ expect(elements.fileBrowserPanel.classList.contains('visible')).toBe(false);
+ if (fail) pending[0].reply.reject(new Error(`late ${kind} failure`));
+ else
+ pending[0].reply.resolve(
+ response(
+ kind === 'tree'
+ ? successfulTree('late.ts')
+ : successfulData({
+ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }],
+ matchCount: 1,
+ })
+ )
+ );
+ if (operation) await operation;
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(app.fileBrowserData).toBeNull();
+ expect(app._fileBrowserState.normalState).toBeNull();
+ expect(app._fileBrowserState.matches).toEqual([]);
+ expect(elements.fileBrowserTree.innerHTML).toBe('');
+ expect(elements.fileBrowserStatus.textContent).toBe('');
+ });
+
+ it.each([
+ ['stale success', false],
+ ['stale failure', true],
+ ])('reopen after %s starts a replacement and stale settlement cannot clear it', async (_name, fail) => {
+ const { app, elements, pending } = loadPanel();
+ const stale = app.loadFileBrowser('session/A');
+ app.closeFileBrowserPanel();
+ elements.fileBrowserPanel.classList.add('visible');
+ const replacement = app.loadFileBrowser('session/A');
+ const replacementRecord = app._fileBrowserState.treeInFlight;
+
+ expect(replacement).not.toBe(stale);
+ expect(pending).toHaveLength(2);
+ if (fail) pending[0].reply.reject(new Error('stale failure'));
+ else pending[0].reply.resolve(response(successfulTree('stale.ts')));
+ await stale;
+ expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord);
+ expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
+ expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts');
+
+ const reused = app.loadFileBrowser('session/A');
+ expect(reused).toBe(replacement);
+ expect(pending).toHaveLength(2);
+ pending[1].reply.resolve(response(successfulTree('replacement.ts')));
+ await replacement;
+ expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts');
+ });
+ });
+});