diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 8a171fb8..57526bbf 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -3026,7 +3026,7 @@ Object.assign(CodemanApp.prototype, {
const searchInput = this.$?.('fileBrowserSearch');
if (searchInput) searchInput.value = '';
- this._setFileBrowserExpandDisabled(false);
+ this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
@@ -3068,7 +3068,7 @@ Object.assign(CodemanApp.prototype, {
const searchInput = this.$?.('fileBrowserSearch');
if (searchInput) searchInput.value = '';
- this._setFileBrowserExpandDisabled(false);
+ this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
const treeEl = this.$?.('fileBrowserTree');
@@ -3082,6 +3082,18 @@ Object.assign(CodemanApp.prototype, {
if (btn) btn.disabled = disabled;
},
+ _hasFileBrowserQuery() {
+ const state = this._ensureFileBrowserState();
+ const input = this.$?.('fileBrowserSearch');
+ const inputValue = typeof input?.value === 'string' ? input.value : '';
+ const filterValue = typeof state.filter === 'string' ? state.filter : '';
+ return inputValue.trim() !== '' || filterValue.trim() !== '';
+ },
+
+ _syncFileBrowserExpandBtn() {
+ this._setFileBrowserExpandDisabled(this._hasFileBrowserQuery());
+ },
+
_renderFileBrowserNormalStatus(data, showHidden) {
const statusEl = this.$('fileBrowserStatus');
if (!statusEl || !data) return;
@@ -3148,7 +3160,7 @@ Object.assign(CodemanApp.prototype, {
if (normalState.phase !== 'ready') return;
this.fileBrowserData = normalState.data;
- this._setFileBrowserExpandDisabled(false);
+ this._syncFileBrowserExpandBtn();
this.renderFileBrowserTree(normalState.sessionId);
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
},
@@ -3229,15 +3241,70 @@ Object.assign(CodemanApp.prototype, {
if (statusEl) statusEl.textContent = message;
},
+ _canContinueFileBrowserHiddenReload(continuation, normalState) {
+ const state = this._ensureFileBrowserState();
+ const input = this.$?.('fileBrowserSearch');
+ const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
+ return (
+ state.view === 'normal' &&
+ state.searchEpoch === continuation.searchEpoch &&
+ state.treeEpoch === continuation.treeEpoch &&
+ state.ownerSessionId === continuation.ownerSessionId &&
+ this.activeSessionId === continuation.ownerSessionId &&
+ (this.fileBrowserShowHidden === true) === continuation.showHidden &&
+ state.filter === continuation.rawInput &&
+ currentInput === continuation.rawInput &&
+ currentInput.trim() === continuation.query &&
+ this.$?.('fileBrowserPanel')?.classList.contains('visible') === true &&
+ normalState?.phase === 'ready' &&
+ this._isFileBrowserNormalCompatible(
+ normalState,
+ continuation.ownerSessionId,
+ continuation.showHidden,
+ continuation.treeEpoch,
+ )
+ );
+ },
+
async toggleFileBrowserHidden() {
+ const state = this._ensureFileBrowserState();
+ const rawInput = typeof state.filter === 'string' ? state.filter : '';
+ const query = rawInput.trim();
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
+
+ if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
+ clearTimeout(state.inFlight.timer);
+ }
+ state.searchEpoch++;
+ state.inFlight = null;
+ state.matches = [];
+ state.deferredDirectoryTarget = null;
+ state.normalState = null;
+ this.fileBrowserData = null;
+ if (query.length <= 256) state.view = 'normal';
+ this._syncFileBrowserExpandBtn();
+
// Expanded-directory state is deliberately preserved so toggling does not
// collapse the tree the user just navigated.
- if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId, { force: true });
+ const ownerSessionId = state.ownerSessionId || this.activeSessionId;
+ if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return;
+
+ const searchEpoch = state.searchEpoch;
+ const showHidden = this.fileBrowserShowHidden === true;
+ const load = this.loadFileBrowser(ownerSessionId, { force: true });
+ const treeEpoch = state.treeEpoch;
+ if (!load?.then) return;
+ await load;
+
+ if (!query || query.length > 256) return;
+ const continuation = { ownerSessionId, showHidden, treeEpoch, searchEpoch, rawInput, query };
+ if (this._canContinueFileBrowserHiddenReload(continuation, state.normalState)) {
+ this.filterFileBrowser(rawInput);
+ }
},
loadFileBrowser(sessionId, { force = false } = {}) {
@@ -3416,6 +3483,7 @@ Object.assign(CodemanApp.prototype, {
state.filter = rawInput;
state.deferredDirectoryTarget = null;
this.fileBrowserFilter = rawInput;
+ this._syncFileBrowserExpandBtn();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
@@ -3428,7 +3496,7 @@ Object.assign(CodemanApp.prototype, {
if (!query) {
state.view = 'normal';
state.matches = [];
- this._setFileBrowserExpandDisabled(false);
+ this._syncFileBrowserExpandBtn();
const normal = state.normalState;
if (
ownerSessionId &&
@@ -3448,7 +3516,7 @@ Object.assign(CodemanApp.prototype, {
const message = 'Search queries are limited to 256 characters';
state.view = 'query-error';
state.matches = [];
- this._setFileBrowserExpandDisabled(true);
+ this._syncFileBrowserExpandBtn();
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
if (treeEl) treeEl.innerHTML = `
${escapeHtml(message)}
`;
@@ -3472,7 +3540,7 @@ Object.assign(CodemanApp.prototype, {
state.view = 'search-pending';
state.matches = [];
state.inFlight = request;
- this._setFileBrowserExpandDisabled(true);
+ this._syncFileBrowserExpandBtn();
treeEl.innerHTML = `${escapeHtml('Searching...')}
`;
const statusEl = this.$('fileBrowserStatus');
if (statusEl) statusEl.textContent = 'Searching...';
@@ -3508,6 +3576,16 @@ Object.assign(CodemanApp.prototype, {
_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 {
@@ -3540,7 +3618,7 @@ Object.assign(CodemanApp.prototype, {
item.addEventListener('click', () => {
const path = item.dataset.path;
if (item.dataset.type === 'directory') {
- this._ensureFileBrowserState().deferredDirectoryTarget = { ownerSessionId, path };
+ this._openFileBrowserSearchDirectory({ ...searchContext, path });
} else {
this.openFilePreview(path, ownerSessionId);
}
@@ -3554,6 +3632,132 @@ Object.assign(CodemanApp.prototype, {
}
},
+ _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 || !this._isFileBrowserDirectoryContextCurrent(target)) return;
+ if (
+ !this._isFileBrowserNormalCompatible(
+ normalState,
+ target.ownerSessionId,
+ target.showHidden,
+ target.treeEpoch,
+ ) ||
+ (normalState.phase !== 'ready' && normalState.phase !== 'error')
+ ) {
+ return;
+ }
+
+ state.deferredDirectoryTarget = null;
+ if (normalState.phase === 'error') {
+ 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);
+ },
+
+ _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) {
for (const node of nodes) {
if (node.type === 'directory') {
@@ -3570,6 +3774,10 @@ Object.assign(CodemanApp.prototype, {
},
toggleFileBrowserExpand() {
+ if (this._hasFileBrowserQuery()) {
+ this._syncFileBrowserExpandBtn();
+ return;
+ }
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
const btn = this.$('fileBrowserExpandBtn');
@@ -3597,11 +3805,11 @@ Object.assign(CodemanApp.prototype, {
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs.clear();
this.fileBrowserAllExpanded = false;
- this._setFileBrowserExpandDisabled(false);
const expandBtn = this.$('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
const searchInput = this.$('fileBrowserSearch');
if (searchInput) searchInput.value = '';
+ this._syncFileBrowserExpandBtn();
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts
index cdd38c34..28040b55 100644
--- a/test/file-browser-search.test.ts
+++ b/test/file-browser-search.test.ts
@@ -189,6 +189,7 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
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();
@@ -317,6 +318,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
}
async function startSearch(app: Record, query = 'widget') {
+ const input = app.$('fileBrowserSearch');
+ if (input) input.value = query;
app.filterFileBrowser(query);
await vi.advanceTimersByTimeAsync(250);
}
@@ -471,14 +474,21 @@ describe('File Viewer server search', () => {
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._fileBrowserState.deferredDirectoryTarget).toEqual({
+ expect(app._openFileBrowserSearchDirectory).toHaveBeenCalledWith({
ownerSessionId: 'session/A',
+ showHidden: false,
+ treeEpoch: 0,
+ searchEpoch: 1,
+ rawInput: 'widget',
+ query: 'widget',
path: 'docs/widgets',
+ view: 'search-results',
});
});
@@ -730,6 +740,594 @@ describe('File Viewer server search', () => {
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',
+ });
+
+ pending[0].reply.resolve(
+ response(successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }))
+ );
+ await treeLoad;
+
+ expect(app._fileBrowserState.view).toBe('normal');
+ expect(elements.fileBrowserTree.innerHTML).toContain('index.ts');
+ 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);
+ }
+ );
+ });
+
describe('normal tree loads', () => {
it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => {
const ready = loadPanel();