mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(file-viewer): COD-341 add server-side search
This commit is contained in:
+233
-34
@@ -2980,6 +2980,81 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
btn.setAttribute('aria-label', label);
|
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 = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||||
|
if (statusEl) statusEl.textContent = message;
|
||||||
|
},
|
||||||
|
|
||||||
async toggleFileBrowserHidden() {
|
async toggleFileBrowserHidden() {
|
||||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||||
try {
|
try {
|
||||||
@@ -2994,6 +3069,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
async loadFileBrowser(sessionId) {
|
async loadFileBrowser(sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
state.treeEpoch++;
|
||||||
|
state.ownerSessionId = sessionId;
|
||||||
const treeEl = this.$('fileBrowserTree');
|
const treeEl = this.$('fileBrowserTree');
|
||||||
const statusEl = this.$('fileBrowserStatus');
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
this._syncFileBrowserHiddenBtn();
|
this._syncFileBrowserHiddenBtn();
|
||||||
@@ -3011,23 +3089,30 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!result.success) throw new Error(result.error || 'Failed to load files');
|
if (!result.success) throw new Error(result.error || 'Failed to load files');
|
||||||
|
|
||||||
this.fileBrowserData = result.data;
|
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
|
// Update status
|
||||||
if (statusEl) {
|
if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden);
|
||||||
const { totalFiles, totalDirectories, truncated } = result.data;
|
|
||||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load file browser:', err);
|
console.error('Failed to load file browser:', err);
|
||||||
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
|
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renderFileBrowserTree() {
|
renderFileBrowserTree(ownerSessionId) {
|
||||||
const treeEl = this.$('fileBrowserTree');
|
const treeEl = this.$('fileBrowserTree');
|
||||||
if (!treeEl || !this.fileBrowserData) return;
|
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;
|
const { tree } = this.fileBrowserData;
|
||||||
if (!tree || tree.length === 0) {
|
if (!tree || tree.length === 0) {
|
||||||
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
|
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
|
||||||
@@ -3035,21 +3120,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = [];
|
const html = [];
|
||||||
const filter = this.fileBrowserFilter.toLowerCase();
|
|
||||||
|
|
||||||
const renderNode = (node, depth) => {
|
const renderNode = (node, depth) => {
|
||||||
const isDir = node.type === 'directory';
|
const isDir = node.type === 'directory';
|
||||||
const isExpanded = this.fileBrowserExpandedDirs.has(node.path);
|
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
|
const icon = isDir
|
||||||
? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1')
|
? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1')
|
||||||
@@ -3066,11 +3140,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||||
|
|
||||||
const downloadBtn = !isDir
|
const downloadBtn = !isDir
|
||||||
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
html.push(`
|
html.push(`
|
||||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
|
||||||
${expandIcon}
|
${expandIcon}
|
||||||
<span class="file-tree-icon">${icon}</span>
|
<span class="file-tree-icon">${icon}</span>
|
||||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||||
@@ -3102,21 +3176,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (type === 'directory') {
|
if (type === 'directory') {
|
||||||
this.toggleFileBrowserFolder(path);
|
this.toggleFileBrowserFolder(path);
|
||||||
} else {
|
} 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) {
|
toggleFileBrowserFolder(path) {
|
||||||
if (this.fileBrowserExpandedDirs.has(path)) {
|
if (this.fileBrowserExpandedDirs.has(path)) {
|
||||||
this.fileBrowserExpandedDirs.delete(path);
|
this.fileBrowserExpandedDirs.delete(path);
|
||||||
@@ -3127,12 +3192,146 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
filterFileBrowser(value) {
|
filterFileBrowser(value) {
|
||||||
this.fileBrowserFilter = value;
|
const state = this._ensureFileBrowserState();
|
||||||
// Auto-expand all if filtering
|
const rawInput = String(value ?? '');
|
||||||
if (value) {
|
const query = rawInput.trim();
|
||||||
this.expandAllDirectories(this.fileBrowserData?.tree || []);
|
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 = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||||
|
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 = `<div class="file-browser-loading">${escapeHtml('Searching...')}</div>`;
|
||||||
|
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 = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
|
||||||
|
} 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
|
||||||
|
? `<span class="file-tree-size">${this.formatFileSize(match.size)}</span>`
|
||||||
|
: '';
|
||||||
|
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||||
|
const downloadBtn = !isDir
|
||||||
|
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||||
|
<span class="file-tree-expand"></span>
|
||||||
|
<span class="file-tree-icon">${icon}</span>
|
||||||
|
<span class="${nameClass}">${escapeHtml(match.name)}</span>
|
||||||
|
<span class="file-tree-path" title="${escapeHtml(match.path)}">${escapeHtml(match.path)}</span>
|
||||||
|
${sizeStr}
|
||||||
|
${downloadBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.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) {
|
expandAllDirectories(nodes) {
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
addEventListener: (type: string, handler: ClickHandler) => void;
|
||||||
|
click: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeElement {
|
||||||
|
innerHTML: string;
|
||||||
|
textContent: string;
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
attrs: Record<string, string>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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 = /<div class="file-tree-item[^"]*"([^>]*)>/g;
|
||||||
|
for (const match of value.matchAll(rowPattern)) {
|
||||||
|
const attributes = match[1];
|
||||||
|
const dataset: Record<string, string> = {};
|
||||||
|
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<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
const elements: Record<string, FakeElement> = {
|
||||||
|
fileBrowserPanel: fakeElement(['visible']),
|
||||||
|
fileBrowserTree: fakeElement(),
|
||||||
|
fileBrowserStatus: fakeElement(),
|
||||||
|
fileBrowserSearch: fakeElement(),
|
||||||
|
fileBrowserExpandBtn: fakeElement(),
|
||||||
|
fileBrowserHiddenBtn: fakeElement(),
|
||||||
|
};
|
||||||
|
const pending: Array<{ url: string; reply: ReturnType<typeof deferred<FakeResponse>> }> = [];
|
||||||
|
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<FakeResponse>();
|
||||||
|
pending.push({ url, reply });
|
||||||
|
return reply.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||||
|
|
||||||
|
const app = new CodemanApp() as Record<string, any>;
|
||||||
|
app.$ = (id: string) => elements[id] ?? null;
|
||||||
|
app.activeSessionId = options.sessionId === undefined ? 'session/A' : options.sessionId;
|
||||||
|
app.fileBrowserData = null;
|
||||||
|
app.fileBrowserExpandedDirs = new Set<string>();
|
||||||
|
app.fileBrowserFilter = '';
|
||||||
|
app.fileBrowserAllExpanded = false;
|
||||||
|
app.fileBrowserShowHidden = options.showHidden ?? false;
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
|
||||||
|
return { app, elements, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSearch(app: Record<string, any>, query = 'widget') {
|
||||||
|
app.filterFileBrowser(query);
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleSearch(
|
||||||
|
app: Record<string, any>,
|
||||||
|
pending: Array<{ url: string; reply: ReturnType<typeof deferred<FakeResponse>> }>,
|
||||||
|
body: unknown,
|
||||||
|
ok = true
|
||||||
|
) {
|
||||||
|
await startSearch(app);
|
||||||
|
pending[0].reply.resolve(response(body, ok));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function successfulData(overrides: Record<string, unknown> = {}) {
|
||||||
|
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</span>');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-name directory">widgets</span>');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-size">1.5 KB</span>');
|
||||||
|
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('<div class="file-browser-empty">Search failed</div>');
|
||||||
|
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: '<b>server detail</b>' });
|
||||||
|
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toBe('<div class="file-browser-empty">Search failed</div>');
|
||||||
|
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('<div class="file-browser-empty">Search failed</div>');
|
||||||
|
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 = '<img src=x onerror="boom"> &\'';
|
||||||
|
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('<img src=x');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user