/**
* @fileoverview The Files panel's row for a folder the scan stopped short of.
*
* `GET .../files` gives a directory its node before reading it, so a folder still queued when the
* budget ends, or one that did not answer, has `children: []` or a partial list
* (test/routes/file-search-mode.test.ts, test/routes/file-walk-bounds.test.ts). Without
* `incomplete` that reads as an empty folder. What can go wrong here is the client: the flag lost
* on the way in, the row drawn for a collapsed or finished folder, or the click wiring treating it
* as a file.
*
* panels-ui.js loads via `vm` as in file-browser-worktree.test.ts, over a real DOM, since the row
* has to survive `innerHTML` and the `.file-tree-item` selector. Translation goes through the real
* i18n.js. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('
');
afterAll(() => dom.window.close());
const document = dom.window.document;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
function loadPanel(expanded: string[] = []) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = 'sess-1';
app.fileBrowserExpandedDirs = new Set(expanded);
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
return app;
}
const bulk = {
name: 'a-bulk',
path: 'a-bulk',
type: 'directory',
incomplete: true,
children: [{ name: 'one.txt', path: 'a-bulk/one.txt', type: 'file', size: 1, extension: 'txt' }],
};
const later = {
name: 'z-later',
path: 'z-later',
type: 'directory',
children: [{ name: 'needle.ts', path: 'z-later/needle.ts', type: 'file', size: 1, extension: 'ts' }],
};
const envelope = (tree: unknown[]) => ({
success: true,
data: { root: '/repo', tree, totalFiles: 2, totalDirectories: 2, truncated: true },
});
const render = (app: Record, tree: unknown[]) => {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(envelope(tree));
app.renderFileBrowserTree('sess-1');
return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[];
};
describe('Files panel, a folder the scan stopped short of', () => {
it('keeps the flag through the envelope check, which ignores what it does not know', () => {
const app = loadPanel();
const data = app._validateFileBrowserTreeEnvelope(envelope([bulk, later]));
expect(data.tree[0].incomplete).toBe(true);
// An old tab on a new server, and a new tab on an old server: neither is refused.
expect(app._validateFileBrowserTreeEnvelope(envelope([later]))).not.toBeNull();
});
it('draws one row under an expanded incomplete folder, after what was read', () => {
const rows = render(loadPanel(['a-bulk']), [bulk, later]);
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toBe('Not fully scanned');
expect(rows[0].dataset.depth).toBe('1');
const items = [...treeEl().children].map((el) => (el as HTMLElement).dataset.path ?? 'ROW');
expect(items).toEqual(['a-bulk', 'a-bulk/one.txt', 'ROW', 'z-later']);
});
it('draws it for a folder nothing was read from, which would otherwise look empty', () => {
const unread = { name: 'b-unread', path: 'b-unread', type: 'directory', incomplete: true, children: [] };
const rows = render(loadPanel(['b-unread']), [unread]);
expect(rows).toHaveLength(1);
expect(treeEl().querySelectorAll('.file-tree-item')).toHaveLength(1);
});
it('indents the row one level under a nested folder', () => {
const nested = { name: 'outer', path: 'outer', type: 'directory', children: [bulk] };
const rows = render(loadPanel(['outer', 'a-bulk']), [nested]);
expect(rows[0].dataset.depth).toBe('2');
});
it('draws nothing while the folder is collapsed', () => {
expect(render(loadPanel(), [bulk, later])).toHaveLength(0);
});
it('draws nothing under an expanded folder that was finished', () => {
expect(render(loadPanel(['z-later']), [bulk, later])).toHaveLength(0);
});
it('accepts only a real true, so a stray truthy value draws nothing', () => {
const odd = { ...bulk, incomplete: 'false' };
expect(render(loadPanel(['a-bulk']), [odd])).toHaveLength(0);
});
it('is inert: a click neither opens nor toggles anything', () => {
const app = loadPanel(['a-bulk']);
const toggle = vi.spyOn(app, 'toggleFileBrowserFolder');
const [row] = render(app, [bulk, later]);
row.click();
expect(row.matches('.file-tree-item')).toBe(false);
expect(row.dataset.path).toBeUndefined();
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(toggle).not.toHaveBeenCalled();
// The file beside it still opens, so the click wiring did run.
(treeEl().querySelector('[data-path="a-bulk/one.txt"]') as HTMLElement).click();
expect(app.openFilePreview).toHaveBeenCalledWith('a-bulk/one.txt', 'sess-1');
});
it('is indented by the same steps as the rows around it', () => {
for (let depth = 1; depth <= 9; depth++) {
expect(STYLES).toContain(
`.file-tree-item[data-depth="${depth}"], .file-tree-incomplete[data-depth="${depth}"] {`
);
}
});
it('reads in Chinese, through the string the row really shows', () => {
const [row] = render(loadPanel(['a-bulk']), [bulk]);
const i18n = new JSDOM('', { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, i18n.getInternalVMContext(), { filename: 'i18n.js' });
const api = (
i18n.window as unknown as {
CodemanI18n: { t(s: string): string; configure(o: Record): void; start(): void };
}
).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const translated = api.t(row.textContent as string);
expect(translated).not.toBe(row.textContent);
expect(translated).not.toMatch(/[A-Za-z]/);
i18n.window.close();
});
});