${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(';)