mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Conflicts resolved against #604, #606 and #601: - constants.js: the rooted link pattern takes html-md's `markdown` and `html?` with #606's m4v/ogv; #604's relative pattern gets the same list (link-provider-regex pins the two equal); #604's findFilePathLinks / folder helpers and html-md's rendersAsPage() are both kept, and window.CodemanFilePaths exports all of them. - terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links) now also goes through rendersAsPage(), so every link kind routes .html to the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS. - CLAUDE.md, api-reference, architecture-invariants: both sides' text kept. - test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other panels-ui harnesses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
/**
|
||
* @fileoverview Folder grid, client side.
|
||
*
|
||
* - Folder paths are links (`findFilePathLinks` marks them `folder: true`): rooted
|
||
* and `~/` paths whose last segment has no extension, relative ones only with a
|
||
* trailing slash. File links win any overlap; prose and URLs stay plain text.
|
||
* - Both consumers route a folder link to `openFolderGrid`.
|
||
* - `openFolderGrid` grants the folder (`POST …/folders`), renders tiles from the
|
||
* folder's by-name routes, and falls back to the file preview when the server
|
||
* finds a file there.
|
||
* - A tile's preview renders from the folder routes (never the attachment
|
||
* registry), pages with ‹ › / ←/→ (wrapping), and the arrows are kept away from
|
||
* the terminal; "Show folder" opens the grid of a previewed file's folder.
|
||
*
|
||
* Real code under test: constants.js + panels-ui.js in a `vm` context against a
|
||
* stub app and a minimal fake DOM (no jsdom), as in file-preview-detach.test.ts.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||
|
||
type Found = { path: string; index: number; folder?: true };
|
||
const constants = vm.createContext({});
|
||
vm.runInContext(`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath };`, constants, {
|
||
filename: 'constants.js',
|
||
});
|
||
const { findFilePathLinks, resolveLinkedFilePath } = (
|
||
constants as unknown as {
|
||
__h: { findFilePathLinks: (t: string) => Found[]; resolveLinkedFilePath: (p: string, w?: string) => string };
|
||
}
|
||
).__h;
|
||
const folders = (text: string) =>
|
||
findFilePathLinks(text)
|
||
.filter((f) => f.folder)
|
||
.map((f) => f.path);
|
||
|
||
describe('folder links (findFilePathLinks)', () => {
|
||
it('links rooted, ~/ and slash-terminated relative folders', () => {
|
||
expect(folders('post mal /mnt/raid1/neon_getaway/styleframes')).toEqual(['/mnt/raid1/neon_getaway/styleframes']);
|
||
expect(folders('ls ~/repos/neon-menu/assets/')).toEqual(['~/repos/neon-menu/assets/']);
|
||
expect(folders('see builds/captures/ next')).toEqual(['builds/captures/']);
|
||
expect(folders('cd /home/joe/.codeman/app.')).toEqual(['/home/joe/.codeman/app']);
|
||
expect(folders('in (/mnt/raid1/foo), ok')).toEqual(['/mnt/raid1/foo']);
|
||
});
|
||
|
||
it('leaves files, prose, bare roots and URLs alone', () => {
|
||
expect(folders('/home/joe/a.png and /home/joe/a.rs')).toEqual([]);
|
||
expect(folders('Node.js and/or foo')).toEqual([]);
|
||
expect(folders('/home/ alone, ~/ alone')).toEqual([]);
|
||
expect(folders('https://x.io/home/joe/dir and http://host/a/')).toEqual([]);
|
||
expect(folders('/etc/nginx is not a link root')).toEqual([]);
|
||
});
|
||
|
||
it('ends a folder path at a colon (PATH lists), and stays linear on colon runs', () => {
|
||
expect(folders('export PATH=/home/u/bin:/usr/bin')).toEqual(['/home/u/bin']);
|
||
expect(folders('dirs /mnt/a/frames:/mnt/b/frames')).toEqual(['/mnt/a/frames', '/mnt/b/frames']);
|
||
const start = Date.now();
|
||
findFilePathLinks('a:'.repeat(16000) + '//');
|
||
expect(Date.now() - start).toBeLessThan(100);
|
||
});
|
||
|
||
it('keeps the file link where both could match, and orders by position', () => {
|
||
const found = findFilePathLinks('dir /mnt/a/frames then /mnt/a/frames/x.png');
|
||
expect(found.map((f) => [f.path, !!f.folder])).toEqual([
|
||
['/mnt/a/frames', true],
|
||
['/mnt/a/frames/x.png', false],
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('folder links are routed to the grid', () => {
|
||
it('terminal provider opens folder links in the grid', () => {
|
||
const src = read('terminal-ui.js');
|
||
expect(src).toMatch(/addLink\(found\.path, found\.index, !!found\.folder\)/);
|
||
expect(src).toMatch(/if \(folder\) \{\s*self\.openFolderGrid\(target, sessionId\);/);
|
||
});
|
||
|
||
it('response viewer marks folder links and opens them in the grid', () => {
|
||
const src = read('app.js');
|
||
expect(src).toMatch(/if \(folder\) link\.dataset\.folder = '1';/);
|
||
expect(src).toMatch(/pathLink\.dataset\.folder\) this\.openFolderGrid\(/);
|
||
});
|
||
|
||
it('ships the grid overlay and the preview buttons', () => {
|
||
const html = read('index.html');
|
||
for (const id of [
|
||
'folderGridOverlay',
|
||
'folderGridTitle',
|
||
'folderGridFolders',
|
||
'folderGridBody',
|
||
'folderGridFooter',
|
||
'folderGridUpBtn',
|
||
'filePreviewPrevBtn',
|
||
'filePreviewNextBtn',
|
||
'filePreviewFolderBtn',
|
||
]) {
|
||
expect(html, id).toContain(`id="${id}"`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------- panels-ui.js against a stub app ----------
|
||
|
||
type FakeEl = {
|
||
tagName: string;
|
||
children: FakeEl[];
|
||
textContent: string;
|
||
innerHTML: string;
|
||
className: string;
|
||
dataset: Record<string, string>;
|
||
hidden?: boolean;
|
||
disabled?: boolean;
|
||
title?: string;
|
||
src?: string;
|
||
onclick?: () => void;
|
||
onerror?: () => void;
|
||
classList: { add: (c: string) => void; remove: (c: string) => void; contains: (c: string) => boolean };
|
||
appendChild: (c: FakeEl) => void;
|
||
append: (...c: FakeEl[]) => void;
|
||
querySelector: (sel: string) => FakeEl | null;
|
||
querySelectorAll: (sel: string) => FakeEl[];
|
||
scrollIntoView: () => void;
|
||
};
|
||
|
||
function el(tagName = 'div'): FakeEl {
|
||
const classes = new Set<string>();
|
||
const node: FakeEl = {
|
||
tagName: tagName.toUpperCase(),
|
||
children: [],
|
||
textContent: '',
|
||
className: '',
|
||
dataset: {},
|
||
get innerHTML() {
|
||
return '';
|
||
},
|
||
set innerHTML(_v: string) {
|
||
node.children = [];
|
||
},
|
||
classList: {
|
||
add: (c) => classes.add(c),
|
||
remove: (c) => classes.delete(c),
|
||
contains: (c) => classes.has(c) || node.className.split(' ').includes(c),
|
||
},
|
||
appendChild: (c) => void node.children.push(c),
|
||
append: (...c) => void node.children.push(...c),
|
||
querySelector: () => null,
|
||
querySelectorAll: () => [],
|
||
scrollIntoView: () => {},
|
||
} as FakeEl;
|
||
return node;
|
||
}
|
||
|
||
function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown) {
|
||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
|
||
const keyHandlers: Array<(ev: unknown) => void> = [];
|
||
const context = vm.createContext({
|
||
CodemanApp,
|
||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||
escapeHtml: (s: string) => String(s),
|
||
CodemanBase: { base: '', url: (p: string) => p },
|
||
resolveLinkedFilePath,
|
||
// Also from constants.js: no file in this suite renders as an HTML page.
|
||
rendersAsPage: () => false,
|
||
document: {
|
||
getElementById: () => null,
|
||
createElement: (t: string) => el(t),
|
||
addEventListener: (type: string, fn: (ev: unknown) => void) => {
|
||
if (type === 'keydown') keyHandlers.push(fn);
|
||
},
|
||
},
|
||
window: { addEventListener: vi.fn(), open: vi.fn() },
|
||
setTimeout,
|
||
clearTimeout,
|
||
confirm: () => true,
|
||
fetch: vi.fn(async (url: string, init?: { body?: string }) => fetchImpl(url, init)),
|
||
});
|
||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||
|
||
const previewOverlay = el();
|
||
const gridOverlay = el();
|
||
const previewBody = el();
|
||
const gridBody = el();
|
||
const elements: Record<string, FakeEl> = {
|
||
filePreviewOverlay: previewOverlay,
|
||
filePreviewBody: previewBody,
|
||
filePreviewTitle: el(),
|
||
filePreviewFooter: el(),
|
||
filePreviewDetachBtn: el(),
|
||
filePreviewPrevBtn: Object.assign(el(), { hidden: true }),
|
||
filePreviewNextBtn: Object.assign(el(), { hidden: true }),
|
||
filePreviewFolderBtn: Object.assign(el(), { hidden: true }),
|
||
folderGridOverlay: gridOverlay,
|
||
folderGridTitle: el(),
|
||
folderGridFolders: el(),
|
||
folderGridBody: gridBody,
|
||
folderGridFooter: el(),
|
||
folderGridUpBtn: el(),
|
||
};
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const app = new CodemanApp() as Record<string, any>;
|
||
app.$ = (id: string) => elements[id] ?? null;
|
||
app._resetFilePreviewEdit = () => {};
|
||
app.showToast = vi.fn();
|
||
app.formatFileSize = (n: number) => `${n} B`;
|
||
app.sessions = new Map([['s1', { workingDir: '/home/joe/wiki' }]]);
|
||
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-x' }));
|
||
return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> };
|
||
}
|
||
|
||
const LISTING = {
|
||
folderId: 'fld_1',
|
||
path: '/mnt/raid1/styleframes',
|
||
files: [
|
||
{ name: 'b.png', extension: 'png', attachmentType: 'image', size: 10, mtimeMs: 2 },
|
||
{ name: 'deck.pdf', extension: 'pdf', attachmentType: 'pdf', size: 20, mtimeMs: 1 },
|
||
{ name: 'clip.mp4', extension: 'mp4', attachmentType: 'video', size: 30, mtimeMs: 0 },
|
||
],
|
||
folders: ['old'],
|
||
truncated: false,
|
||
};
|
||
|
||
function ok(data: unknown) {
|
||
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
|
||
}
|
||
|
||
describe('openFolderGrid', () => {
|
||
it('grants the folder and renders a tile per file from the folder routes', async () => {
|
||
const { app, elements, fetch } = loadApp(() => ok(LISTING));
|
||
await app.openFolderGrid('~/styleframes', 's1');
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/sessions/s1/folders', expect.objectContaining({ method: 'POST' }));
|
||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '~/styleframes' });
|
||
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
|
||
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/styleframes');
|
||
|
||
const tiles = elements.folderGridBody.children;
|
||
expect(tiles).toHaveLength(3);
|
||
const thumb = (i: number) => tiles[i].children[0];
|
||
expect(thumb(0).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
|
||
expect(thumb(1).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/deck.pdf/thumbnail');
|
||
expect(thumb(2).children).toHaveLength(0);
|
||
expect(thumb(2).textContent).toBe('MP4');
|
||
expect(elements.folderGridFolders.children.map((c) => c.textContent)).toEqual(['old/']);
|
||
expect(elements.folderGridFooter.textContent).toBe('3 files • 1 folder');
|
||
});
|
||
|
||
it('falls back to the file preview when the path is a file', async () => {
|
||
const { app } = loadApp(() => ({ ok: false, status: 400, json: async () => ({ error: 'Not a folder' }) }));
|
||
app.openFilePreview = vi.fn();
|
||
await app.openFolderGrid('/mnt/raid1/notes', 's1');
|
||
expect(app.openFilePreview).toHaveBeenCalledWith('/mnt/raid1/notes', 's1');
|
||
});
|
||
|
||
it('toasts a missing or blocked folder', async () => {
|
||
const { app, elements } = loadApp(() => ({
|
||
ok: false,
|
||
status: 404,
|
||
json: async () => ({ error: 'Folder not found' }),
|
||
}));
|
||
await app.openFolderGrid('/mnt/raid1/gone', 's1');
|
||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Folder not found'), 'error');
|
||
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('folder preview paging', () => {
|
||
async function openGrid() {
|
||
const ctx = loadApp(() => ok(LISTING));
|
||
await ctx.app.openFolderGrid('/mnt/raid1/styleframes', 's1');
|
||
return ctx;
|
||
}
|
||
|
||
it('previews a tile from the folder routes, never registering an attachment', async () => {
|
||
const { app, elements } = await openGrid();
|
||
elements.folderGridBody.children[0].onclick?.();
|
||
await Promise.resolve();
|
||
|
||
expect(app._registerExternalPreview).not.toHaveBeenCalled();
|
||
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
|
||
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/styleframes/b.png');
|
||
expect(elements.filePreviewPrevBtn.hidden).toBe(false);
|
||
expect(elements.filePreviewNextBtn.hidden).toBe(false);
|
||
expect(elements.filePreviewFolderBtn.hidden).toBe(true);
|
||
});
|
||
|
||
it('pages with wrap-around and keeps arrow keys away from the terminal', async () => {
|
||
const { app, elements, keyHandlers } = await openGrid();
|
||
elements.folderGridBody.children[0].onclick?.();
|
||
elements.filePreviewOverlay.classList.add('visible');
|
||
|
||
app.stepFolderPreview(-1);
|
||
expect(app.folderGrid.index).toBe(2);
|
||
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/clip.mp4/raw');
|
||
|
||
const ev = {
|
||
key: 'ArrowRight',
|
||
target: { tagName: 'TEXTAREA', classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
|
||
preventDefault: vi.fn(),
|
||
stopPropagation: vi.fn(),
|
||
};
|
||
keyHandlers.forEach((h) => h(ev));
|
||
expect(app.folderGrid.index).toBe(0);
|
||
expect(ev.stopPropagation).toHaveBeenCalled();
|
||
|
||
const typing = {
|
||
...ev,
|
||
target: { tagName: 'INPUT', classList: { contains: () => false } },
|
||
stopPropagation: vi.fn(),
|
||
};
|
||
keyHandlers.forEach((h) => h(typing));
|
||
expect(app.folderGrid.index).toBe(0);
|
||
expect(typing.stopPropagation).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Escape closes the preview first, then the grid', async () => {
|
||
const { app, elements, keyHandlers } = await openGrid();
|
||
elements.folderGridBody.children[1].onclick?.();
|
||
elements.filePreviewOverlay.classList.add('visible');
|
||
const esc = () => ({ key: 'Escape', target: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||
|
||
keyHandlers.forEach((h) => h(esc()));
|
||
expect(elements.filePreviewOverlay.classList.contains('visible')).toBe(false);
|
||
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
|
||
expect(app.folderGrid.index).toBeNull();
|
||
|
||
keyHandlers.forEach((h) => h(esc()));
|
||
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
|
||
expect(app.folderGrid).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('stale loads', () => {
|
||
const TEXT_LISTING = {
|
||
...LISTING,
|
||
path: '/mnt/raid1/logs',
|
||
files: [
|
||
{ name: 'a.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 2 },
|
||
{ name: 'b.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 1 },
|
||
],
|
||
};
|
||
const text = (body: string) => ({ ok: true, status: 200, text: async () => body });
|
||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||
|
||
it("paging past a text file that is still loading never shows its text under the next file's title", async () => {
|
||
const pending: Record<string, (v: unknown) => void> = {};
|
||
const { app, elements } = loadApp((url) =>
|
||
url.endsWith('/folders') ? ok(TEXT_LISTING) : new Promise((r) => (pending[url] = r))
|
||
);
|
||
await app.openFolderGrid('/mnt/raid1/logs', 's1');
|
||
app._renderFilePreviewText = vi.fn();
|
||
elements.folderGridBody.children[0].onclick?.();
|
||
elements.filePreviewOverlay.classList.add('visible');
|
||
app.stepFolderPreview(1);
|
||
await flush();
|
||
|
||
pending['/api/sessions/s1/folders/fld_1/files/b.log/raw'](text('B'));
|
||
await flush();
|
||
pending['/api/sessions/s1/folders/fld_1/files/a.log/raw'](text('A'));
|
||
await flush();
|
||
|
||
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/logs/b.log');
|
||
expect(app.filePreviewContent).toBe('B');
|
||
expect(app.filePreviewText.filePath).toBe('/mnt/raid1/logs/b.log');
|
||
expect(app._renderFilePreviewText).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('two quick folder opens render only the newer one, and a closed grid stays closed', async () => {
|
||
const pending: Record<string, (v: unknown) => void> = {};
|
||
const { app, elements } = loadApp((_url, init) => {
|
||
const path = JSON.parse(init?.body || '{}').path;
|
||
return new Promise((r) => (pending[path] = r));
|
||
});
|
||
const first = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
|
||
const second = app.openFolderGrid('/mnt/raid1/logs', 's1');
|
||
pending['/mnt/raid1/logs'](ok(TEXT_LISTING));
|
||
await second;
|
||
pending['/mnt/raid1/styleframes'](ok(LISTING));
|
||
await first;
|
||
expect(app.folderGrid.path).toBe('/mnt/raid1/logs');
|
||
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/logs');
|
||
|
||
const late = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
|
||
app.closeFolderGrid();
|
||
pending['/mnt/raid1/styleframes'](ok(LISTING));
|
||
await late;
|
||
expect(app.folderGrid).toBeNull();
|
||
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('"Show folder" in the preview', () => {
|
||
it('knows the folder of absolute, relative and ~/ paths, not of a bare attachment card', () => {
|
||
const { app } = loadApp(() => ok(LISTING));
|
||
expect(app._previewFolderPath('/mnt/raid1/x/a.png', 's1', null)).toEqual({ dir: '/mnt/raid1/x', sessionId: 's1' });
|
||
expect(app._previewFolderPath('builds/a.png', 's1', null)).toEqual({
|
||
dir: '/home/joe/wiki/builds',
|
||
sessionId: 's1',
|
||
});
|
||
expect(app._previewFolderPath('~/r/a.png', 's1', null)).toEqual({ dir: '~/r', sessionId: 's1' });
|
||
expect(app._previewFolderPath('a.png', 's1', 'att-1')).toBeNull();
|
||
});
|
||
|
||
it('shows the button for a previewed file and opens its folder', async () => {
|
||
const { app, elements, fetch } = loadApp(() => ok(LISTING));
|
||
await app.openFilePreview('/mnt/raid1/styleframes/b.png', 's1');
|
||
expect(elements.filePreviewFolderBtn.hidden).toBe(false);
|
||
expect(elements.filePreviewPrevBtn.hidden).toBe(true);
|
||
|
||
app.openFolderOfPreview();
|
||
await vi.waitFor(() => expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true));
|
||
expect(JSON.parse(fetch.mock.calls.at(-1)[1].body)).toEqual({ path: '/mnt/raid1/styleframes' });
|
||
});
|
||
});
|