diff --git a/src/web/public/app.js b/src/web/public/app.js
index 52e97b14..57177ade 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -5633,6 +5633,7 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
+ this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258).
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 26ffa2c1..8a171fb8 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -3002,6 +3002,81 @@ Object.assign(CodemanApp.prototype, {
return this._fileBrowserState;
},
+ _activateFileBrowserSession(sessionId) {
+ if (!sessionId) return;
+
+ const state = this._ensureFileBrowserState();
+ if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
+ clearTimeout(state.inFlight.timer);
+ }
+ state.searchEpoch++;
+ state.treeEpoch++;
+ state.ownerSessionId = sessionId;
+ state.treeInFlight = null;
+ state.inFlight = null;
+ state.normalState = null;
+ state.matches = [];
+ state.deferredDirectoryTarget = null;
+ state.filter = '';
+ state.view = 'normal';
+ this.fileBrowserData = null;
+ this.fileBrowserFilter = '';
+ this.fileBrowserExpandedDirs?.clear?.();
+ this.fileBrowserAllExpanded = false;
+
+ const searchInput = this.$?.('fileBrowserSearch');
+ if (searchInput) searchInput.value = '';
+ this._setFileBrowserExpandDisabled(false);
+ const expandBtn = this.$?.('fileBrowserExpandBtn');
+ if (expandBtn) expandBtn.innerHTML = '\u229E';
+
+ const panel = this.$?.('fileBrowserPanel');
+ const treeEl = this.$?.('fileBrowserTree');
+ const statusEl = this.$?.('fileBrowserStatus');
+ const visible = panel?.classList.contains('visible') === true;
+ if (treeEl) {
+ treeEl.innerHTML = visible
+ ? `
${escapeHtml('Loading files...')}
`
+ : '';
+ }
+ if (statusEl) statusEl.textContent = visible ? 'Loading files...' : '';
+
+ if (visible) {
+ const load = this.loadFileBrowser?.(sessionId);
+ load?.catch?.(() => {});
+ }
+ },
+
+ _resetFileBrowserForHide() {
+ const state = this._ensureFileBrowserState();
+ if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
+ clearTimeout(state.inFlight.timer);
+ }
+ state.searchEpoch++;
+ state.treeEpoch++;
+ state.treeInFlight = null;
+ state.inFlight = null;
+ state.normalState = null;
+ state.matches = [];
+ state.deferredDirectoryTarget = null;
+ state.filter = '';
+ state.view = 'normal';
+ this.fileBrowserData = null;
+ this.fileBrowserFilter = '';
+ this.fileBrowserExpandedDirs?.clear?.();
+ this.fileBrowserAllExpanded = false;
+
+ const searchInput = this.$?.('fileBrowserSearch');
+ if (searchInput) searchInput.value = '';
+ this._setFileBrowserExpandDisabled(false);
+ const expandBtn = this.$?.('fileBrowserExpandBtn');
+ if (expandBtn) expandBtn.innerHTML = '\u229E';
+ const treeEl = this.$?.('fileBrowserTree');
+ if (treeEl) treeEl.innerHTML = '';
+ const statusEl = this.$?.('fileBrowserStatus');
+ if (statusEl) statusEl.textContent = '';
+ },
+
_setFileBrowserExpandDisabled(disabled) {
const btn = this.$('fileBrowserExpandBtn');
if (btn) btn.disabled = disabled;
@@ -3561,6 +3636,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 9d9a42bd..ab74c30b 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-search.test.ts b/test/file-browser-search.test.ts
index f144a090..5bc6fb28 100644
--- a/test/file-browser-search.test.ts
+++ b/test/file-browser-search.test.ts
@@ -12,7 +12,9 @@ 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 panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
+const settingsJs = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
type ClickHandler = () => void;
@@ -34,9 +36,11 @@ interface FakeElement {
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[];
}
@@ -106,11 +110,15 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
textContent: '',
value: '',
disabled: false,
+ style: {},
attrs,
classList,
setAttribute(name, value) {
attrs[name] = value;
},
+ querySelector() {
+ return null;
+ },
querySelectorAll(selector) {
return selector === '.file-tree-item' ? rows : [];
},
@@ -153,7 +161,12 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
console,
escapeHtml,
localStorage: { getItem: () => null, setItem: vi.fn() },
- document: { getElementById: () => null, addEventListener: vi.fn(), querySelector: vi.fn() },
+ document: {
+ getElementById: (id: string) => elements[id] ?? null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ querySelector: vi.fn(),
+ },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
@@ -163,6 +176,7 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
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;
@@ -174,6 +188,9 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
app.fileBrowserAllExpanded = false;
app.fileBrowserShowHidden = options.showHidden ?? false;
app.openFilePreview = vi.fn();
+ app.loadAppSettingsFromStorage = () => ({ showFileBrowser: false });
+ app.getDefaultSettings = () => ({});
+ app.saveAppSettingsToStorage = vi.fn();
return { app, elements, pending };
}
@@ -856,4 +873,276 @@ describe('File Viewer server search', () => {
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('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');
+ });
+ });
});