From acd9ffedc8b15401e7c421c071f34bea6d8fcec9 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:27:26 -0400 Subject: [PATCH] fix(file-viewer): COD-341 complete search transitions --- src/web/public/panels-ui.js | 226 +++++++++++- test/file-browser-search.test.ts | 600 ++++++++++++++++++++++++++++++- 2 files changed, 816 insertions(+), 10 deletions(-) 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();