From c614331d60ed7f178ec8386f4a588280cd969469 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 21:01:39 -0400 Subject: [PATCH 01/10] feat(file-viewer): COD-341 add server-side search --- src/web/public/panels-ui.js | 267 ++++++++++++++++--- test/file-browser-search.test.ts | 427 +++++++++++++++++++++++++++++++ 2 files changed, 660 insertions(+), 34 deletions(-) create mode 100644 test/file-browser-search.test.ts diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 52a4c8ea..4ad14e01 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2980,6 +2980,81 @@ Object.assign(CodemanApp.prototype, { btn.setAttribute('aria-label', label); }, + _ensureFileBrowserState() { + if (!this._fileBrowserState) { + const ownerSessionId = this.activeSessionId || null; + const showHidden = this.fileBrowserShowHidden === true; + this._fileBrowserState = { + treeEpoch: 0, + searchEpoch: 0, + ownerSessionId, + view: 'normal', + normalState: this.fileBrowserData + ? { ownerSessionId, showHidden, data: this.fileBrowserData } + : null, + inFlight: null, + matches: [], + deferredDirectoryTarget: null, + filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '', + }; + } + return this._fileBrowserState; + }, + + _setFileBrowserExpandDisabled(disabled) { + const btn = this.$('fileBrowserExpandBtn'); + if (btn) btn.disabled = disabled; + }, + + _renderFileBrowserNormalStatus(data, showHidden) { + const statusEl = this.$('fileBrowserStatus'); + if (!statusEl || !data) return; + const { totalFiles, totalDirectories, truncated } = data; + statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' ยท hidden shown' : ''}`; + }, + + _validateFileBrowserSearchEnvelope(result) { + if (!result || typeof result !== 'object' || result.success !== true) return null; + const data = result.data; + if (!data || typeof data !== 'object' || data.mode !== 'search' || !Array.isArray(data.matches)) return null; + if (typeof data.truncated !== 'boolean') return null; + if ( + data.matchCount !== undefined && + (typeof data.matchCount !== 'number' || !Number.isFinite(data.matchCount) || data.matchCount < 0) + ) { + return null; + } + for (const match of data.matches) { + if (!match || typeof match !== 'object') return null; + if (typeof match.name !== 'string' || typeof match.path !== 'string') return null; + if (match.type !== 'file' && match.type !== 'directory') return null; + if (match.size !== undefined && (typeof match.size !== 'number' || !Number.isFinite(match.size))) return null; + if (match.extension !== undefined && typeof match.extension !== 'string') return null; + } + return data; + }, + + _canRenderFileBrowserSearch(request) { + const state = this._ensureFileBrowserState(); + const panel = this.$('fileBrowserPanel'); + return ( + state.searchEpoch === request.epoch && + state.ownerSessionId === request.ownerSessionId && + this.activeSessionId === request.ownerSessionId && + (this.fileBrowserShowHidden === true) === request.showHidden && + state.filter === request.rawInput && + panel?.classList.contains('visible') === true + ); + }, + + _renderFileBrowserSearchError() { + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + const message = 'Search failed'; + if (treeEl) treeEl.innerHTML = `
${escapeHtml(message)}
`; + if (statusEl) statusEl.textContent = message; + }, + async toggleFileBrowserHidden() { this.fileBrowserShowHidden = !this.fileBrowserShowHidden; try { @@ -2994,6 +3069,9 @@ Object.assign(CodemanApp.prototype, { async loadFileBrowser(sessionId) { if (!sessionId) return; + const state = this._ensureFileBrowserState(); + state.treeEpoch++; + state.ownerSessionId = sessionId; const treeEl = this.$('fileBrowserTree'); const statusEl = this.$('fileBrowserStatus'); this._syncFileBrowserHiddenBtn(); @@ -3011,23 +3089,30 @@ Object.assign(CodemanApp.prototype, { if (!result.success) throw new Error(result.error || 'Failed to load files'); this.fileBrowserData = result.data; - this.renderFileBrowserTree(); + state.normalState = { ownerSessionId: sessionId, showHidden, data: result.data }; + if (state.filter.trim() === '') { + state.view = 'normal'; + state.matches = []; + this._setFileBrowserExpandDisabled(false); + this.renderFileBrowserTree(sessionId); + } // Update status - if (statusEl) { - const { totalFiles, totalDirectories, truncated } = result.data; - statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' ยท hidden shown' : ''}`; - } + if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden); } catch (err) { console.error('Failed to load file browser:', err); treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; } }, - renderFileBrowserTree() { + renderFileBrowserTree(ownerSessionId) { const treeEl = this.$('fileBrowserTree'); if (!treeEl || !this.fileBrowserData) return; + const state = this._ensureFileBrowserState(); + const owner = ownerSessionId || state.normalState?.ownerSessionId || state.ownerSessionId || this.activeSessionId; + if (!owner) return; + const { tree } = this.fileBrowserData; if (!tree || tree.length === 0) { treeEl.innerHTML = '
No files found
'; @@ -3035,21 +3120,10 @@ Object.assign(CodemanApp.prototype, { } const html = []; - const filter = this.fileBrowserFilter.toLowerCase(); const renderNode = (node, depth) => { const isDir = node.type === 'directory'; const isExpanded = this.fileBrowserExpandedDirs.has(node.path); - const matchesFilter = !filter || node.name.toLowerCase().includes(filter); - - // For directories, check if any children match - let hasMatchingChildren = false; - if (isDir && filter && node.children) { - hasMatchingChildren = this.hasMatchingChild(node, filter); - } - - const shouldShow = matchesFilter || hasMatchingChildren; - const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : ''; const icon = isDir ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') @@ -3066,11 +3140,11 @@ Object.assign(CodemanApp.prototype, { const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir - ? `` + ? `` : ''; html.push(` -
+
${expandIcon} ${icon} ${escapeHtml(node.name)} @@ -3102,21 +3176,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 +3192,146 @@ 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; + + 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 (!query) { + state.view = 'normal'; + state.matches = []; + this._setFileBrowserExpandDisabled(false); + const normal = state.normalState; + if ( + ownerSessionId && + normal?.ownerSessionId === ownerSessionId && + normal.showHidden === (this.fileBrowserShowHidden === true) + ) { + this.fileBrowserData = normal.data; + this.renderFileBrowserTree(ownerSessionId); + this._renderFileBrowserNormalStatus(normal.data, normal.showHidden); + } + return; + } + + if (query.length > 256) { + const message = 'Search queries are limited to 256 characters'; + state.view = 'query-error'; + state.matches = []; + this._setFileBrowserExpandDisabled(true); + 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, + ownerSessionId, + showHidden: this.fileBrowserShowHidden === true, + rawInput, + query, + timer: null, + }; + state.view = 'search-pending'; + state.matches = []; + state.inFlight = request; + this._setFileBrowserExpandDisabled(true); + 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; + 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)} + ${escapeHtml(match.path)} + ${sizeStr} + ${downloadBtn} +
+ `; + }) + .join(''); + } + + treeEl.querySelectorAll('.file-tree-item').forEach(item => { + item.addEventListener('click', () => { + const path = item.dataset.path; + if (item.dataset.type === 'directory') { + this._ensureFileBrowserState().deferredDirectoryTarget = { ownerSessionId, 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(); }, expandAllDirectories(nodes) { diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts new file mode 100644 index 00000000..35d70148 --- /dev/null +++ b/test/file-browser-search.test.ts @@ -0,0 +1,427 @@ +/** + * @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 panelsJs = readFileSync(resolve(PUBLIC, 'panels-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; + attrs: Record; + classList: FakeClassList; + setAttribute: (name: string, value: string) => void; + 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, + attrs, + classList, + setAttribute(name, value) { + attrs[name] = value; + }, + 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: () => null, addEventListener: 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(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(); + + return { app, elements, pending }; +} + +async function startSearch(app: Record, query = 'widget') { + 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, + }, + }; +} + +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('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('src/widget.ts'); + 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.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({ + ownerSessionId: 'session/A', + path: 'docs/widgets', + }); + }); + + 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('does not render a completion after the panel is hidden and releases its in-flight pointer', async () => { + const { app, elements, pending } = loadPanel(); + await startSearch(app); + elements.fileBrowserPanel.classList.remove('visible'); + + pending[0].reply.resolve( + response(successfulData({ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }], matchCount: 1 })) + ); + await vi.advanceTimersByTimeAsync(0); + + expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('late.ts'); + expect(app._fileBrowserState.inFlight).toBeNull(); + }); + + 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('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(' Date: Sun, 23 Aug 2026 21:09:52 -0400 Subject: [PATCH 02/10] fix(file-viewer): COD-341 gate stale search results --- src/web/public/panels-ui.js | 3 + test/file-browser-search.test.ts | 151 ++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 13 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 4ad14e01..0209ba3c 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3039,6 +3039,7 @@ Object.assign(CodemanApp.prototype, { const panel = this.$('fileBrowserPanel'); return ( state.searchEpoch === request.epoch && + state.treeEpoch === request.treeEpoch && state.ownerSessionId === request.ownerSessionId && this.activeSessionId === request.ownerSessionId && (this.fileBrowserShowHidden === true) === request.showHidden && @@ -3207,6 +3208,7 @@ Object.assign(CodemanApp.prototype, { 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 = []; @@ -3242,6 +3244,7 @@ Object.assign(CodemanApp.prototype, { const request = { epoch: state.searchEpoch, + treeEpoch: state.treeEpoch, ownerSessionId, showHidden: this.fileBrowserShowHidden === true, rawInput, diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index 35d70148..a179428f 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -249,6 +249,49 @@ describe('File Viewer server search', () => { 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( @@ -295,19 +338,75 @@ describe('File Viewer server search', () => { expect(elements.fileBrowserStatus.textContent).toBe('0 matches'); }); - it('does not render a completion after the panel is hidden and releases its in-flight pointer', async () => { - const { app, elements, pending } = loadPanel(); - await startSearch(app); - elements.fileBrowserPanel.classList.remove('visible'); - - pending[0].reply.resolve( - response(successfulData({ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }], matchCount: 1 })) - ); - await vi.advanceTimersByTimeAsync(0); - - expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); - expect(elements.fileBrowserTree.innerHTML).not.toContain('late.ts'); - expect(app._fileBrowserState.inFlight).toBeNull(); + 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: 'tree epoch', + mutate: (app) => { + app._fileBrowserState.treeEpoch++; + }, + }, + { + 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('clears a blank query and restores the compatible cached tree immediately without fetching', () => { @@ -329,6 +428,32 @@ describe('File Viewer server search', () => { 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)} `); From 3af36f7c3442564ac46eb33e6d0ea3270d32ce50 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 21:23:17 -0400 Subject: [PATCH 03/10] fix(file-viewer): COD-341 preserve search row layout --- src/web/public/panels-ui.js | 1 - test/file-browser-search.test.ts | 37 +++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 0209ba3c..237c09e3 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3310,7 +3310,6 @@ Object.assign(CodemanApp.prototype, { ${icon} ${escapeHtml(match.name)} - ${escapeHtml(match.path)} ${sizeStr} ${downloadBtn}
diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index a179428f..b3ffe5b2 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -312,7 +312,8 @@ describe('File Viewer server search', () => { 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('src/widget.ts'); + 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"' ); @@ -409,6 +410,40 @@ describe('File Viewer server search', () => { } }); + 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 = { From dab8e6643c5ea53644764b2198ba853bdb90b31e Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 21:40:10 -0400 Subject: [PATCH 04/10] fix(file-viewer): COD-341 deduplicate normal tree loads --- src/web/public/panels-ui.js | 247 ++++++++++++++++++++++----- test/file-browser-hidden.test.ts | 9 +- test/file-browser-search.test.ts | 284 ++++++++++++++++++++++++++++++- 3 files changed, 488 insertions(+), 52 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 237c09e3..26ffa2c1 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2990,8 +2990,9 @@ Object.assign(CodemanApp.prototype, { ownerSessionId, view: 'normal', normalState: this.fileBrowserData - ? { ownerSessionId, showHidden, data: this.fileBrowserData } + ? { sessionId: ownerSessionId, showHidden, treeEpoch: 0, phase: 'ready', data: this.fileBrowserData } : null, + treeInFlight: null, inFlight: null, matches: [], deferredDirectoryTarget: null, @@ -3013,6 +3014,104 @@ Object.assign(CodemanApp.prototype, { statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' ยท hidden shown' : ''}`; }, + _isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) { + return ( + candidate?.sessionId === sessionId && + candidate.showHidden === showHidden && + candidate.treeEpoch === treeEpoch + ); + }, + + _isFileBrowserTreeContextCurrent(request, requireCurrentRecord = false) { + const state = this._ensureFileBrowserState(); + return ( + (!requireCurrentRecord || state.treeInFlight === request) && + state.ownerSessionId === request.sessionId && + state.treeEpoch === request.treeEpoch && + (this.fileBrowserShowHidden === true) === request.showHidden + ); + }, + + _canRenderFileBrowserNormal(normalState) { + const state = this._ensureFileBrowserState(); + return ( + state.view === 'normal' && + this.activeSessionId === normalState?.sessionId && + this._isFileBrowserNormalCompatible( + normalState, + state.ownerSessionId, + this.fileBrowserShowHidden === true, + state.treeEpoch, + ) && + this.$('fileBrowserPanel')?.classList.contains('visible') === true + ); + }, + + _renderFileBrowserNormalState(normalState) { + if (!normalState || !this._canRenderFileBrowserNormal(normalState)) return; + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + if (!treeEl) return; + + if (normalState.phase === 'loading') { + this.fileBrowserData = null; + treeEl.innerHTML = `
${escapeHtml('Loading files...')}
`; + if (statusEl) statusEl.textContent = 'Loading files...'; + return; + } + + if (normalState.phase === 'error') { + this.fileBrowserData = null; + const detail = normalState.error && normalState.error !== 'Failed to load files' + ? `: ${normalState.error}` + : ''; + const message = `Failed to load files${detail}`; + treeEl.innerHTML = `
${escapeHtml(message)}
`; + if (statusEl) statusEl.textContent = message; + return; + } + + if (normalState.phase !== 'ready') return; + this.fileBrowserData = normalState.data; + this._setFileBrowserExpandDisabled(false); + this.renderFileBrowserTree(normalState.sessionId); + this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden); + }, + + _validateFileBrowserTreeEnvelope(result) { + if (!result || typeof result !== 'object' || result.success !== true) return null; + const data = result.data; + if (!data || typeof data !== 'object' || !Array.isArray(data.tree)) return null; + if (data.mode === 'search') return null; + if ( + typeof data.totalFiles !== 'number' || + !Number.isFinite(data.totalFiles) || + data.totalFiles < 0 || + typeof data.totalDirectories !== 'number' || + !Number.isFinite(data.totalDirectories) || + data.totalDirectories < 0 || + typeof data.truncated !== 'boolean' + ) { + return null; + } + + const validNodes = nodes => nodes.every(node => { + if (!node || typeof node !== 'object') return false; + if (typeof node.name !== 'string' || typeof node.path !== 'string') return false; + if (node.type !== 'file' && node.type !== 'directory') return false; + if (node.size !== undefined && (typeof node.size !== 'number' || !Number.isFinite(node.size))) return false; + if (node.extension !== undefined && typeof node.extension !== 'string') return false; + if (node.children !== undefined && (!Array.isArray(node.children) || !validNodes(node.children))) return false; + return true; + }); + + return validNodes(data.tree) ? data : null; + }, + + _normalizeFileBrowserTreeError(error) { + return typeof error?.message === 'string' && error.message ? error.message : 'Failed to load files'; + }, + _validateFileBrowserSearchEnvelope(result) { if (!result || typeof result !== 'object' || result.success !== true) return null; const data = result.data; @@ -3039,7 +3138,6 @@ Object.assign(CodemanApp.prototype, { const panel = this.$('fileBrowserPanel'); return ( state.searchEpoch === request.epoch && - state.treeEpoch === request.treeEpoch && state.ownerSessionId === request.ownerSessionId && this.activeSessionId === request.ownerSessionId && (this.fileBrowserShowHidden === true) === request.showHidden && @@ -3064,46 +3162,89 @@ Object.assign(CodemanApp.prototype, { this._syncFileBrowserHiddenBtn(); // Expanded-directory state is deliberately preserved so toggling does not // collapse the tree the user just navigated. - if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId); + if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId, { force: true }); }, - async loadFileBrowser(sessionId) { - if (!sessionId) return; + loadFileBrowser(sessionId, { force = false } = {}) { + if (!sessionId) return undefined; const state = this._ensureFileBrowserState(); - state.treeEpoch++; - state.ownerSessionId = sessionId; const treeEl = this.$('fileBrowserTree'); - const statusEl = this.$('fileBrowserStatus'); this._syncFileBrowserHiddenBtn(); - if (!treeEl) return; - - // Show loading state - treeEl.innerHTML = '
Loading files...
'; + if (!treeEl) return undefined; + if (!state.ownerSessionId) state.ownerSessionId = sessionId; + if (state.ownerSessionId !== sessionId) return undefined; + + if (force) state.treeEpoch++; + const showHidden = this.fileBrowserShowHidden === true; + const treeEpoch = state.treeEpoch; + const inFlight = state.treeInFlight; + if ( + !force && + this._isFileBrowserNormalCompatible(inFlight, sessionId, showHidden, treeEpoch) + ) { + return inFlight.promise; + } - try { - const showHidden = this.fileBrowserShowHidden === true; - const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`); - if (!res.ok) throw new Error('Failed to load files'); + const settled = state.normalState; + if ( + !force && + this._isFileBrowserNormalCompatible(settled, sessionId, showHidden, treeEpoch) && + (settled.phase === 'ready' || settled.phase === 'error') + ) { + if (settled.phase === 'ready') this.fileBrowserData = settled.data; + this._renderFileBrowserNormalState(settled); + return Promise.resolve(settled); + } - const result = await res.json(); - if (!result.success) throw new Error(result.error || 'Failed to load files'); + const loadingState = { sessionId, showHidden, treeEpoch, phase: 'loading' }; + state.normalState = loadingState; + this.fileBrowserData = null; + this._renderFileBrowserNormalState(loadingState); - this.fileBrowserData = result.data; - state.normalState = { ownerSessionId: sessionId, showHidden, data: result.data }; - if (state.filter.trim() === '') { - state.view = 'normal'; - state.matches = []; - this._setFileBrowserExpandDisabled(false); - this.renderFileBrowserTree(sessionId); + const record = { sessionId, showHidden, treeEpoch, promise: null }; + const request = (async () => { + try { + const res = await fetch( + `/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`, + ); + if (!res.ok) throw new Error('Failed to load files'); + const result = await res.json(); + const data = this._validateFileBrowserTreeEnvelope(result); + if (!data) { + const detail = result && typeof result === 'object' && typeof result.error === 'string' + ? result.error + : 'Failed to load files'; + throw new Error(detail); + } + if (!this._isFileBrowserTreeContextCurrent(record, true)) return; + + const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data }; + state.normalState = nextNormalState; + this.fileBrowserData = data; + this._completeDeferredFileBrowserDirectory?.(nextNormalState); + this._renderFileBrowserNormalState(nextNormalState); + } catch (error) { + if (!this._isFileBrowserTreeContextCurrent(record, true)) return; + const nextNormalState = { + sessionId, + showHidden, + treeEpoch, + phase: 'error', + error: this._normalizeFileBrowserTreeError(error), + }; + state.normalState = nextNormalState; + this.fileBrowserData = null; + this._completeDeferredFileBrowserDirectory?.(nextNormalState); + console.error('Failed to load file browser:', error); + this._renderFileBrowserNormalState(nextNormalState); } - - // Update status - if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden); - } catch (err) { - console.error('Failed to load file browser:', err); - treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; - } + })(); + record.promise = request.finally(() => { + if (state.treeInFlight === record) state.treeInFlight = null; + }); + state.treeInFlight = record; + return record.promise; }, renderFileBrowserTree(ownerSessionId) { @@ -3111,7 +3252,7 @@ Object.assign(CodemanApp.prototype, { if (!treeEl || !this.fileBrowserData) return; const state = this._ensureFileBrowserState(); - const owner = ownerSessionId || state.normalState?.ownerSessionId || state.ownerSessionId || this.activeSessionId; + const owner = ownerSessionId || state.normalState?.sessionId || state.ownerSessionId || this.activeSessionId; if (!owner) return; const { tree } = this.fileBrowserData; @@ -3216,12 +3357,14 @@ Object.assign(CodemanApp.prototype, { const normal = state.normalState; if ( ownerSessionId && - normal?.ownerSessionId === ownerSessionId && - normal.showHidden === (this.fileBrowserShowHidden === true) + this._isFileBrowserNormalCompatible( + normal, + ownerSessionId, + this.fileBrowserShowHidden === true, + state.treeEpoch, + ) ) { - this.fileBrowserData = normal.data; - this.renderFileBrowserTree(ownerSessionId); - this._renderFileBrowserNormalStatus(normal.data, normal.showHidden); + this._renderFileBrowserNormalState(normal); } return; } @@ -3366,14 +3509,28 @@ Object.assign(CodemanApp.prototype, { }, refreshFileBrowser() { - if (this.activeSessionId) { - this.fileBrowserExpandedDirs.clear(); - this.fileBrowserFilter = ''; - this.fileBrowserAllExpanded = false; - const searchInput = this.$('fileBrowserSearch'); - if (searchInput) searchInput.value = ''; - this.loadFileBrowser(this.activeSessionId); + const state = this._ensureFileBrowserState(); + if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) { + clearTimeout(state.inFlight.timer); } + state.inFlight = null; + state.searchEpoch++; + state.filter = ''; + state.matches = []; + state.deferredDirectoryTarget = null; + state.view = 'normal'; + this.fileBrowserFilter = ''; + this.fileBrowserExpandedDirs.clear(); + this.fileBrowserAllExpanded = false; + this._setFileBrowserExpandDisabled(false); + const expandBtn = this.$('fileBrowserExpandBtn'); + if (expandBtn) expandBtn.innerHTML = '\u229E'; + const searchInput = this.$('fileBrowserSearch'); + if (searchInput) searchInput.value = ''; + + const ownerSessionId = state.ownerSessionId || this.activeSessionId; + if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined; + return this.loadFileBrowser(ownerSessionId, { force: true }); }, // Header "File Viewer" button (opt-in via App Settings โ†’ Header Displays โ†’ diff --git a/test/file-browser-hidden.test.ts b/test/file-browser-hidden.test.ts index f5817fc3..a867d680 100644 --- a/test/file-browser-hidden.test.ts +++ b/test/file-browser-hidden.test.ts @@ -38,7 +38,7 @@ interface FakeElement { textContent: string; classes: Set; attrs: Record; - classList: { toggle: (name: string, on: boolean) => void }; + classList: { toggle: (name: string, on: boolean) => void; contains: (name: string) => boolean }; setAttribute: (name: string, value: string) => void; } @@ -55,6 +55,9 @@ function fakeElement(): FakeElement { if (on) classes.add(name); else classes.delete(name); }, + contains(name: string) { + return classes.has(name); + }, }, setAttribute(name: string, value: string) { attrs[name] = value; @@ -92,10 +95,12 @@ function loadPanel(store: Map | null) { vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' }); const elements: Record = { + fileBrowserPanel: fakeElement(), fileBrowserTree: fakeElement(), fileBrowserStatus: fakeElement(), fileBrowserHiddenBtn: fakeElement(), }; + elements.fileBrowserPanel.classList.toggle('visible', true); const requests: string[] = []; const app = new CodemanApp() as Record; app.$ = (id: string) => elements[id] ?? null; @@ -149,10 +154,12 @@ describe('File Viewer show-hidden toggle', () => { const { app, requests } = loadPanel(store); await app.loadFileBrowser('sess-1'); expect(requests[0]).toContain('showHidden=false'); + const previousTreeEpoch = app._fileBrowserState.treeEpoch; await app.toggleFileBrowserHidden(); expect(app.fileBrowserShowHidden).toBe(true); + expect(app._fileBrowserState.treeEpoch).toBe(previousTreeEpoch + 1); expect(requests).toHaveLength(2); expect(requests[1]).toContain('showHidden=true'); expect(store.get(STORAGE_KEY)).toBe('1'); diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index b3ffe5b2..f144a090 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -207,6 +207,19 @@ function successfulData(overrides: Record = {}) { }; } +function successfulTree(name: string, overrides: Record = {}) { + return { + success: true, + data: { + tree: [{ name, path: name, type: 'file', size: 1, extension: 'ts' }], + totalFiles: 1, + totalDirectories: 0, + truncated: false, + ...overrides, + }, + }; +} + describe('File Viewer server search', () => { beforeEach(() => { vi.useFakeTimers(); @@ -356,12 +369,6 @@ describe('File Viewer server search', () => { app._fileBrowserState.searchEpoch++; }, }, - { - name: 'tree epoch', - mutate: (app) => { - app._fileBrowserState.treeEpoch++; - }, - }, { name: 'owner', mutate: (app) => { @@ -584,4 +591,269 @@ describe('File Viewer server search', () => { ); expect(html).not.toContain(' { + it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => { + const ready = loadPanel(); + ready.app._completeDeferredFileBrowserDirectory = vi.fn(); + const first = ready.app.loadFileBrowser('session/A'); + const duplicate = ready.app.loadFileBrowser('session/A'); + + expect(duplicate).toBe(first); + expect(ready.pending).toHaveLength(1); + ready.pending[0].reply.resolve(response(successfulTree('ready.ts'))); + await first; + expect(ready.app._fileBrowserState.normalState.phase).toBe('ready'); + expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); + expect(ready.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( + ready.app._fileBrowserState.normalState + ); + + ready.elements.fileBrowserTree.innerHTML = ''; + const reusedReady = ready.app.loadFileBrowser('session/A'); + expect(ready.pending).toHaveLength(1); + expect(typeof reusedReady.then).toBe('function'); + await reusedReady; + expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); + + const failed = loadPanel(); + failed.app._completeDeferredFileBrowserDirectory = vi.fn(); + const failedLoad = failed.app.loadFileBrowser('session/A'); + failed.pending[0].reply.reject(new Error('')); + await failedLoad; + expect(failed.app._fileBrowserState.normalState.phase).toBe('error'); + expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); + expect(failed.elements.fileBrowserTree.innerHTML).not.toContain(''); + expect(failed.elements.fileBrowserStatus.innerHTML).toBe(''); + expect(failed.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( + failed.app._fileBrowserState.normalState + ); + + failed.elements.fileBrowserTree.innerHTML = ''; + await failed.app.loadFileBrowser('session/A'); + expect(failed.pending).toHaveLength(1); + expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); + }); + + it('refresh invalidates both work classes, clears navigation state, and forces an epoch-safe replacement', async () => { + const { app, elements, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + app.filterFileBrowser('old query'); + app.fileBrowserExpandedDirs.add('src'); + app.fileBrowserAllExpanded = true; + app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' }; + const beforeSearchEpoch = app._fileBrowserState.searchEpoch; + const beforeTreeEpoch = app._fileBrowserState.treeEpoch; + + const replacement = app.refreshFileBrowser(); + + expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1); + expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1); + expect(app._fileBrowserState.filter).toBe(''); + expect(app._fileBrowserState.matches).toEqual([]); + expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); + expect(app._fileBrowserState.view).toBe('normal'); + expect(app.fileBrowserExpandedDirs.size).toBe(0); + expect(app.fileBrowserAllExpanded).toBe(false); + expect(elements.fileBrowserExpandBtn.disabled).toBe(false); + expect(elements.fileBrowserSearch.value).toBe(''); + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(pending).toHaveLength(2); + + pending[0].reply.resolve(response(successfulTree('stale.ts'))); + await stale; + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); + expect(app._fileBrowserState.treeInFlight.promise).toBe(replacement); + + pending[1].reply.resolve(response(successfulTree('fresh.ts'))); + await replacement; + expect(elements.fileBrowserTree.innerHTML).toContain('fresh.ts'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); + }); + + it('ignores stale pre-refresh failures while the replacement remains loading', async () => { + const { app, elements, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + const replacement = app.refreshFileBrowser(); + + pending[0].reply.reject(new Error('stale failure')); + await stale; + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale failure'); + + pending[1].reply.resolve(response(successfulTree('replacement.ts'))); + await replacement; + expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts'); + }); + + it('isolates overlapping owners and does not reuse an unresolved A request after A to B to A', async () => { + const { app, elements, pending } = loadPanel(); + const firstA = app.loadFileBrowser('session/A'); + app._fileBrowserState.ownerSessionId = 'session/B'; + app.activeSessionId = 'session/B'; + const loadB = app.loadFileBrowser('session/B'); + + app._fileBrowserState.ownerSessionId = 'session/A'; + app.activeSessionId = 'session/A'; + const secondA = app.loadFileBrowser('session/A'); + expect(secondA).not.toBe(firstA); + expect(pending).toHaveLength(3); + + pending[0].reply.resolve(response(successfulTree('stale-A.ts'))); + await firstA; + expect(app.fileBrowserData).toBeNull(); + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts'); + + pending[1].reply.reject(new Error('late B failure')); + await loadB; + expect(elements.fileBrowserTree.innerHTML).not.toContain('late B failure'); + pending[2].reply.resolve(response(successfulTree('current-A.ts'))); + await secondA; + expect(elements.fileBrowserTree.innerHTML).toContain('current-A.ts'); + }); + + it('shows B loading after clearing search while B is unsettled, never cached A data', async () => { + const { app, elements, pending } = loadPanel(); + const firstA = app.loadFileBrowser('session/A'); + pending[0].reply.resolve(response(successfulTree('cached-A.ts'))); + await firstA; + expect(elements.fileBrowserTree.innerHTML).toContain('cached-A.ts'); + + app._fileBrowserState.ownerSessionId = 'session/B'; + app.activeSessionId = 'session/B'; + const loadB = app.loadFileBrowser('session/B'); + app.filterFileBrowser('temporary'); + app.filterFileBrowser(''); + + expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-A.ts'); + pending[1].reply.resolve(response(successfulTree('current-B.ts'))); + await loadB; + expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts'); + }); + + it('settles a compatible tree behind search without repainting and keeps tree/search epochs independent', async () => { + const { app, elements, pending } = loadPanel(); + const treeLoad = app.loadFileBrowser('session/A'); + app.filterFileBrowser('query A'); + await vi.advanceTimersByTimeAsync(250); + app.filterFileBrowser('query B'); + await vi.advanceTimersByTimeAsync(250); + expect(pending).toHaveLength(3); + + pending[0].reply.resolve(response(successfulTree('behind-search.ts'))); + await treeLoad; + expect(app._fileBrowserState.normalState.phase).toBe('ready'); + expect(app.fileBrowserData.tree[0].name).toBe('behind-search.ts'); + expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-search.ts'); + + app._fileBrowserState.treeEpoch++; + pending[2].reply.resolve( + response(successfulData({ matches: [{ name: 'query-B.ts', path: 'query-B.ts', type: 'file' }] })) + ); + await vi.advanceTimersByTimeAsync(0); + expect(elements.fileBrowserTree.innerHTML).toContain('query-B.ts'); + + pending[1].reply.resolve( + response(successfulData({ matches: [{ name: 'query-A.ts', path: 'query-A.ts', type: 'file' }] })) + ); + await vi.advanceTimersByTimeAsync(0); + expect(elements.fileBrowserTree.innerHTML).not.toContain('query-A.ts'); + }); + + it('suppresses invalidated tree responses and hidden-panel repainting', async () => { + const scenarios = [ + { + name: 'owner', + mutate(app: Record) { + app._fileBrowserState.ownerSessionId = 'session/B'; + }, + }, + { + name: 'tree epoch', + mutate(app: Record) { + app._fileBrowserState.treeEpoch++; + }, + }, + { + name: 'hidden preference', + mutate(app: Record) { + app.fileBrowserShowHidden = true; + }, + }, + ]; + + for (const scenario of scenarios) { + const { app, elements, pending } = loadPanel(); + const load = app.loadFileBrowser('session/A'); + scenario.mutate(app); + pending[0].reply.resolve(response(successfulTree(`late-${scenario.name}.ts`))); + await load; + expect(app.fileBrowserData, scenario.name).toBeNull(); + expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`); + } + + const hidden = loadPanel(); + const hiddenLoad = hidden.app.loadFileBrowser('session/A'); + hidden.elements.fileBrowserPanel.classList.remove('visible'); + hidden.pending[0].reply.resolve(response(successfulTree('hidden-ready.ts'))); + await hiddenLoad; + expect(hidden.app._fileBrowserState.normalState.phase).toBe('ready'); + expect(hidden.elements.fileBrowserTree.innerHTML).toContain('Loading files'); + expect(hidden.elements.fileBrowserTree.innerHTML).not.toContain('hidden-ready.ts'); + }); + + it('compare-and-clears only the exact stale request and preserves replacement deduplication', async () => { + const { app, pending } = loadPanel(); + const stale = app.loadFileBrowser('session/A'); + const replacement = app.loadFileBrowser('session/A', { force: true }); + const replacementRecord = app._fileBrowserState.treeInFlight; + + pending[0].reply.resolve(response(successfulTree('stale.ts'))); + await stale; + expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord); + + const reused = app.loadFileBrowser('session/A'); + expect(reused).toBe(replacement); + expect(pending).toHaveLength(2); + + pending[1].reply.resolve(response(successfulTree('replacement.ts'))); + await replacement; + }); + + it.each([ + ['tree is not an array', successfulTree('x.ts', { tree: {} })], + ['totalFiles is not finite', successfulTree('x.ts', { totalFiles: Number.POSITIVE_INFINITY })], + ['the envelope is search-discriminated', successfulTree('x.ts', { mode: 'search', matches: [], matchCount: 0 })], + [ + 'a nested node is malformed', + successfulTree('x.ts', { tree: [{ name: 'dir', path: 'dir', type: 'directory', children: [{}] }] }), + ], + ])('stores an escaped error instead of malformed tree data when %s', async (_case, body) => { + const { app, elements, pending } = loadPanel(); + const load = app.loadFileBrowser('session/A'); + pending[0].reply.resolve(response(body)); + await load; + + expect(app.fileBrowserData).toBeNull(); + expect(app._fileBrowserState.normalState.phase).toBe('error'); + expect(elements.fileBrowserTree.innerHTML).toContain('Failed to load files'); + }); + + it('does not adopt a different owner or fetch without a usable tree surface', () => { + const wrongOwner = loadPanel(); + wrongOwner.app._ensureFileBrowserState().ownerSessionId = 'session/B'; + expect(wrongOwner.app.loadFileBrowser('session/A')).toBeUndefined(); + expect(wrongOwner.pending).toHaveLength(0); + expect(wrongOwner.app._fileBrowserState.ownerSessionId).toBe('session/B'); + + const missingTree = loadPanel(); + delete missingTree.elements.fileBrowserTree; + expect(missingTree.app.loadFileBrowser('session/A')).toBeUndefined(); + expect(missingTree.pending).toHaveLength(0); + }); + }); }); From f6a1f06633645b2b14ecb65ff3adfd254fc0bd56 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 21:58:45 -0400 Subject: [PATCH 05/10] fix(file-viewer): COD-341 synchronize session lifecycle --- src/web/public/app.js | 1 + src/web/public/panels-ui.js | 76 ++++++++ src/web/public/settings-ui.js | 3 +- test/file-browser-search.test.ts | 291 ++++++++++++++++++++++++++++++- 4 files changed, 369 insertions(+), 2 deletions(-) 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'); + }); + }); }); From 921933775b6c6fbb7f09d3a1c84a5a7bcc148c12 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:08:29 -0400 Subject: [PATCH 06/10] test(file-viewer): COD-341 execute session lifecycle path --- test/file-browser-search.test.ts | 190 +++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index 5bc6fb28..cdd38c34 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -13,6 +13,7 @@ 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'); @@ -195,6 +196,126 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean } 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') { app.filterFileBrowser(query); await vi.advanceTimersByTimeAsync(250); @@ -891,6 +1012,75 @@ describe('File Viewer server search', () => { 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; From acd9ffedc8b15401e7c421c071f34bea6d8fcec9 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:27:26 -0400 Subject: [PATCH 07/10] 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(); From c14171b53462460508e9ead7d86528dc2ce0a001 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:42:10 -0400 Subject: [PATCH 08/10] fix(file-viewer): COD-341 finalize deferred navigation --- src/web/public/panels-ui.js | 17 +++++++++------ test/file-browser-search.test.ts | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 57526bbf..ea00821e 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3364,8 +3364,8 @@ Object.assign(CodemanApp.prototype, { const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data }; state.normalState = nextNormalState; this.fileBrowserData = data; - this._completeDeferredFileBrowserDirectory?.(nextNormalState); - this._renderFileBrowserNormalState(nextNormalState); + const deferredRendered = this._completeDeferredFileBrowserDirectory?.(nextNormalState) === true; + if (!deferredRendered) this._renderFileBrowserNormalState(nextNormalState); } catch (error) { if (!this._isFileBrowserTreeContextCurrent(record, true)) return; const nextNormalState = { @@ -3708,7 +3708,11 @@ Object.assign(CodemanApp.prototype, { _completeDeferredFileBrowserDirectory(normalState) { const state = this._ensureFileBrowserState(); const target = state.deferredDirectoryTarget; - if (!target || !this._isFileBrowserDirectoryContextCurrent(target)) return; + if (!target) return false; + if (!this._isFileBrowserDirectoryContextCurrent(target)) { + if (state.deferredDirectoryTarget === target) state.deferredDirectoryTarget = null; + return false; + } if ( !this._isFileBrowserNormalCompatible( normalState, @@ -3718,21 +3722,22 @@ Object.assign(CodemanApp.prototype, { ) || (normalState.phase !== 'ready' && normalState.phase !== 'error') ) { - return; + return false; } state.deferredDirectoryTarget = null; if (normalState.phase === 'error') { this._promptFileBrowserDirectoryReload(); - return; + return false; } const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path); if (!found) { this._promptFileBrowserDirectoryReload(); - return; + return false; } this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState); + return true; }, _leaveFileBrowserSearchForDirectory(paths, normalState) { diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index 28040b55..13e804b8 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -1183,14 +1183,17 @@ describe('File Viewer server search', () => { 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); }); @@ -1324,8 +1327,41 @@ describe('File Viewer server search', () => { 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', () => { From 858b15e3f50327699319989fea9a6e889f73f4de Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:46:58 -0400 Subject: [PATCH 09/10] chore: COD-341 add File Viewer search release note --- .changeset/calm-files-search.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/calm-files-search.md diff --git a/.changeset/calm-files-search.md b/.changeset/calm-files-search.md new file mode 100644 index 00000000..875342a6 --- /dev/null +++ b/.changeset/calm-files-search.md @@ -0,0 +1,5 @@ +--- +"codeman": patch +--- + +Search the full session workspace from File Viewer while keeping results scoped to the active session and hidden-file preference. From d15d979a33ba5976715efd744187cf54b1cf2c4d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 22:55:10 -0400 Subject: [PATCH 10/10] fix: COD-341 correct changeset package name --- .changeset/calm-files-search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-files-search.md b/.changeset/calm-files-search.md index 875342a6..018c68fa 100644 --- a/.changeset/calm-files-search.md +++ b/.changeset/calm-files-search.md @@ -1,5 +1,5 @@ --- -"codeman": patch +"aicodeman": patch --- Search the full session workspace from File Viewer while keeping results scoped to the active session and hidden-file preference.