mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 03:09:44 +02:00
feat(preview): folder grid — browse every previewable file of a folder
Folder paths in the terminal and the response viewer are now links (rooted or ~/ without an extension, relative with a trailing slash); a click opens a grid of the folder's supported files, newest first, with subfolder chips and a parent button. A tile opens the file preview, which pages through the folder with ‹ › / ←/→; the preview of any file gets a "Show folder" button. Server: POST /api/sessions/:id/folders grants + lists a folder (local fs or one ssh round trip for a remote case) under the attachment guard; GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one file by bare name through resolveServableAttachmentPath, so browser requests still never carry a path. Grants live in their own small registry so a big folder cannot evict the session's attachments. The attachment raw route's body is now the shared serveServableRaw helper. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7d46a9d336
commit
1010d80b4f
@@ -149,6 +149,11 @@ class AttachmentRegistry {
|
||||
|
||||
export const attachmentRegistry = new AttachmentRegistry();
|
||||
|
||||
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
|
||||
export function supportedAttachmentExtensions(): string[] {
|
||||
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
|
||||
}
|
||||
|
||||
export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @fileoverview Folder listings for the file-preview folder grid.
|
||||
*
|
||||
* A folder the user opens (a clicked folder path, or "Folder" in the file
|
||||
* preview) is resolved, checked against the same attachment guard a single file
|
||||
* goes through, and GRANTED an id. The grid then asks for files by
|
||||
* `folderId + file name` only, so browser requests never carry arbitrary paths —
|
||||
* the rule the attachment routes follow for single files.
|
||||
*
|
||||
* Grants live in their own small registry, not in the attachment registry: that one
|
||||
* is capped at 200 records per session, and a folder of 300 renders would evict the
|
||||
* session's real attachments.
|
||||
*
|
||||
* Listings contain non-hidden subfolders and files whose type the preview supports
|
||||
* (`isSupportedAttachmentExtension`), newest first, capped at
|
||||
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
|
||||
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { extname, isAbsolute, join, posix } from 'node:path';
|
||||
import {
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
supportedAttachmentExtensions,
|
||||
} from './attachment-registry.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
|
||||
import type { AttachmentDetectedType } from './types.js';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
|
||||
/** Most entries (files + subfolders) one listing returns. */
|
||||
export const MAX_FOLDER_ENTRIES = 1000;
|
||||
|
||||
/** Folder grants kept per session; the oldest is dropped beyond this. */
|
||||
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
|
||||
|
||||
export interface FolderFileEntry {
|
||||
name: string;
|
||||
extension: string;
|
||||
attachmentType: AttachmentDetectedType;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface FolderListing {
|
||||
folderId: string;
|
||||
/** The folder with symlinks resolved (on the session's host). */
|
||||
path: string;
|
||||
files: FolderFileEntry[];
|
||||
/** Non-hidden subfolder names, sorted. */
|
||||
folders: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export class FolderListingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface FolderGrant {
|
||||
folderId: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
class FolderGrants {
|
||||
private bySession = new Map<string, Map<string, FolderGrant>>();
|
||||
|
||||
grant(sessionId: string, dir: string): string {
|
||||
let grants = this.bySession.get(sessionId);
|
||||
if (!grants) {
|
||||
grants = new Map();
|
||||
this.bySession.set(sessionId, grants);
|
||||
}
|
||||
for (const existing of grants.values()) {
|
||||
if (existing.dir === dir) {
|
||||
// Refresh its position so an often-used folder is the last to be dropped.
|
||||
grants.delete(existing.folderId);
|
||||
grants.set(existing.folderId, existing);
|
||||
return existing.folderId;
|
||||
}
|
||||
}
|
||||
const folderId = `fld_${randomUUID()}`;
|
||||
grants.set(folderId, { folderId, dir });
|
||||
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
|
||||
const oldest = grants.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
grants.delete(oldest);
|
||||
}
|
||||
return folderId;
|
||||
}
|
||||
|
||||
get(sessionId: string, folderId: string): FolderGrant | undefined {
|
||||
return this.bySession.get(sessionId)?.get(folderId);
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
this.bySession.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const folderGrants = new FolderGrants();
|
||||
|
||||
/**
|
||||
* A file name inside a granted folder, or null when it is not one: no path
|
||||
* separator, not `.`/`..`, no NUL, and a type the preview supports.
|
||||
*/
|
||||
export function validFolderFileName(name: unknown): string | null {
|
||||
if (typeof name !== 'string' || !name || name.length > 255) return null;
|
||||
if (name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
|
||||
const extension = extname(name).toLowerCase().replace(/^\./, '');
|
||||
return isSupportedAttachmentExtension(extension) ? name : null;
|
||||
}
|
||||
|
||||
/** Where a granted folder's file lives (on the session's host). */
|
||||
export function folderFilePath(dir: string, name: string): string {
|
||||
return posix.join(dir, name);
|
||||
}
|
||||
|
||||
interface ListFolderOptions {
|
||||
sessionWorkingDir?: string;
|
||||
remote?: SessionRemote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, guard, list and grant a folder for `sessionId`.
|
||||
*
|
||||
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
|
||||
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked, 404 missing, 502 remote unreachable.
|
||||
*/
|
||||
export async function listFolder(
|
||||
sessionId: string,
|
||||
requestedPath: string,
|
||||
options: ListFolderOptions = {}
|
||||
): Promise<FolderListing> {
|
||||
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
|
||||
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new FolderListingError('Folder path must be absolute');
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let entries: RemoteFolderEntry[];
|
||||
let truncated = false;
|
||||
if (options.remote) {
|
||||
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
|
||||
let listing;
|
||||
try {
|
||||
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
|
||||
} catch (err) {
|
||||
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
|
||||
}
|
||||
if (!listing) throw new FolderListingError('Folder not found', 404);
|
||||
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
|
||||
({ dir, entries, truncated } = listing);
|
||||
} else {
|
||||
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
|
||||
try {
|
||||
dir = realpathSync(expanded);
|
||||
} catch {
|
||||
throw new FolderListingError('Folder not found', 404);
|
||||
}
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) throw new FolderListingError('Not a folder');
|
||||
({ entries, truncated } = await listLocalFolder(dir));
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const workingDir = options.sessionWorkingDir;
|
||||
const confined = !guard.confineToWorkspace
|
||||
? true
|
||||
: options.remote
|
||||
? !!workingDir && isUnderTree(dir, workingDir)
|
||||
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
|
||||
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
|
||||
throw new FolderListingError('Access to this folder is blocked', 403);
|
||||
}
|
||||
|
||||
const files: FolderFileEntry[] = [];
|
||||
const folders: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
// A secret that happens to sit in an open folder stays out of the grid too.
|
||||
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
|
||||
if (entry.kind === 'directory') {
|
||||
folders.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
files.push({
|
||||
name: entry.name,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: entry.size,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
||||
folders.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
|
||||
}
|
||||
|
||||
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
|
||||
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const dirent of dirents) {
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
if (entries.length >= MAX_FOLDER_ENTRIES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
|
||||
const maybeFile = isSupportedAttachmentExtension(extension);
|
||||
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(join(dir, dirent.name));
|
||||
} catch {
|
||||
continue; // dangling symlink, or gone since readdir
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
|
||||
} else if (stat.isFile() && maybeFile) {
|
||||
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return { entries, truncated };
|
||||
}
|
||||
@@ -275,6 +275,120 @@ export async function remoteProbePaths(
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
|
||||
export interface RemoteFolderEntry {
|
||||
kind: 'file' | 'directory';
|
||||
name: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
|
||||
export interface RemoteFolderListing {
|
||||
dir: string;
|
||||
entries: RemoteFolderEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
|
||||
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
|
||||
* one of `extensions` (either case), up to `maxEntries`.
|
||||
*
|
||||
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
|
||||
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
|
||||
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
|
||||
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
|
||||
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
|
||||
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
|
||||
*/
|
||||
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
|
||||
const patterns = extensions
|
||||
.flatMap((ext) => [ext.toLowerCase(), ext.toUpperCase()])
|
||||
.map((ext) => `*.${ext}`)
|
||||
.join('|');
|
||||
return [
|
||||
`d=${shellescape(dir)}`,
|
||||
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
|
||||
"printf '\\0'",
|
||||
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
|
||||
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
|
||||
'printf \'D|%s\\0\' "$r"',
|
||||
'n=0',
|
||||
'for f in "$r"/*; do',
|
||||
' [ -e "$f" ] || continue',
|
||||
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
|
||||
' b=${f##*/}',
|
||||
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
|
||||
' [ -f "$f" ] || continue',
|
||||
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
|
||||
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
|
||||
' n=$((n + 1))',
|
||||
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
|
||||
'done',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
|
||||
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
|
||||
*/
|
||||
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
|
||||
const records = stdout.split('\0').slice(1);
|
||||
let dir: string | null = null;
|
||||
let truncated = false;
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
for (const record of records) {
|
||||
if (record === 'E|f') return 'not-a-folder';
|
||||
if (record.startsWith('E|')) return null;
|
||||
if (record.startsWith('D|')) {
|
||||
dir = record.slice(2);
|
||||
continue;
|
||||
}
|
||||
if (record === 'T|') {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
|
||||
if (!match) continue;
|
||||
const size = Number.parseInt(match[2], 10);
|
||||
const mtime = Number.parseInt(match[3], 10);
|
||||
entries.push({
|
||||
kind: match[1] === 'd' ? 'directory' : 'file',
|
||||
name: match[4],
|
||||
size: Number.isFinite(size) && size > 0 ? size : 0,
|
||||
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
|
||||
});
|
||||
}
|
||||
if (!dir) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable folder listing');
|
||||
}
|
||||
return { dir, entries, truncated };
|
||||
}
|
||||
|
||||
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
|
||||
export async function remoteListFolder(
|
||||
remote: SessionRemote,
|
||||
dir: string,
|
||||
extensions: readonly string[],
|
||||
maxEntries: number
|
||||
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
|
||||
assertNotUnderTest();
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
|
||||
);
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
}
|
||||
return parseRemoteFolderListOutput(stdout);
|
||||
}
|
||||
|
||||
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||
assertNotUnderTest();
|
||||
|
||||
@@ -2749,7 +2749,8 @@ class CodemanApp {
|
||||
// (rebased, no `linkified` mark) already are what the routes expect.
|
||||
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
|
||||
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
|
||||
if (filePath) this.openFilePreview(target || filePath, sessionId);
|
||||
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
|
||||
else if (filePath) this.openFilePreview(target || filePath, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2902,13 +2903,14 @@ class CodemanApp {
|
||||
const value = node.nodeValue;
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const { path, index } of found) {
|
||||
for (const { path, index, folder } of found) {
|
||||
if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
|
||||
const link = document.createElement('a');
|
||||
link.className = 'rv-path';
|
||||
link.href = '#';
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
if (folder) link.dataset.folder = '1';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
|
||||
@@ -2004,13 +2004,15 @@ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LIN
|
||||
|
||||
/**
|
||||
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
|
||||
* then `~/` and relative ones that do not overlap them or a URL.
|
||||
* then `~/` and relative ones, then folder paths — each only where it overlaps
|
||||
* neither an earlier one nor a URL.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
* viewer), so a path form is clickable in both or in neither.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{path: string, index: number}>} `index` is where `path` starts in `text`.
|
||||
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
|
||||
* `text`; `folder` marks a folder path (folder grid) rather than a file.
|
||||
*/
|
||||
function findFilePathLinks(text) {
|
||||
const value = String(text || '');
|
||||
@@ -2036,9 +2038,47 @@ function findFilePathLinks(text) {
|
||||
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
||||
found.push({ path, index });
|
||||
}
|
||||
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
|
||||
while ((match = folders.exec(value)) !== null) {
|
||||
const path = folderLinkPath(match[2]);
|
||||
const index = match.index + match[1].length;
|
||||
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
||||
found.push({ path, index, folder: true });
|
||||
}
|
||||
return found.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
|
||||
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
|
||||
* trailing slash (`builds/captures/`). Same boundary in front as the relative
|
||||
* file pattern (group 1), the candidate itself is group 2 and goes through
|
||||
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
|
||||
* File links are found first and win any overlap.
|
||||
*
|
||||
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const FOLDER_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*)/g;
|
||||
|
||||
/**
|
||||
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
|
||||
*
|
||||
* Sentence punctuation after the path is not part of it. A path ending in `/` is
|
||||
* a folder; otherwise only a rooted or `~/` path whose last segment has no
|
||||
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
|
||||
* without a slash at the end is prose, not a folder.
|
||||
*/
|
||||
function folderLinkPath(candidate) {
|
||||
const path = String(candidate || '').replace(/[.,:;!?'"]+$/, '');
|
||||
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
|
||||
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
|
||||
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
|
||||
const last = path.slice(path.lastIndexOf('/') + 1);
|
||||
return /^\.?[^.]+$/.test(last) ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a clicked path points, as the preview routes need it.
|
||||
*
|
||||
|
||||
@@ -614,6 +614,9 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button>
|
||||
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button>
|
||||
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
@@ -634,6 +637,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay">
|
||||
<div class="folder-grid-window">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
|
||||
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
|
||||
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
|
||||
+270
-7
@@ -4086,8 +4086,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
|
||||
* granted folder (folder grid): rendered from the folder's by-name routes, so
|
||||
* paging through 300 renders never touches the attachment registry.
|
||||
*/
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
|
||||
if (!sessionId || !filePath) return;
|
||||
const folderFile = options.folderFile || null;
|
||||
// Prev/next only while browsing a folder grid; a preview opened from anywhere
|
||||
// else ends that browsing.
|
||||
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
|
||||
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
const titleEl = this.$('filePreviewTitle');
|
||||
@@ -4125,7 +4136,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// for a file that is sitting right there on disk.
|
||||
let externalError = '';
|
||||
let externalSize = 0;
|
||||
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
const external = await this._registerExternalPreview(filePath, sessionId);
|
||||
attachmentId = external.attachmentId || null;
|
||||
externalError = external.error || '';
|
||||
@@ -4143,9 +4154,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
const byIdBase = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
|
||||
: folderFile
|
||||
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
|
||||
: null;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
byIdBase
|
||||
? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
@@ -4155,8 +4171,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
// A folder-grid file renders the same way, from its folder's by-name routes.
|
||||
if (byIdBase) {
|
||||
const base = CodemanBase.url(byIdBase);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4166,6 +4183,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Size when we just registered the file ourselves, so a path opened from a
|
||||
// link reads like a workspace preview instead of a bare "PNG". History
|
||||
// cards arrive with an id and no size and keep the short form.
|
||||
if (folderFile && !externalSize) externalSize = options.size || 0;
|
||||
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
@@ -4204,7 +4222,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = shown;
|
||||
// attachmentId: a card's filePath is the bare file name, so the
|
||||
// rebase pass must know there is no directory to resolve against.
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
@@ -4307,9 +4325,254 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Folder grid =====
|
||||
|
||||
/**
|
||||
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
|
||||
* session's host). The server grants the folder an id (`POST …/folders`), and the
|
||||
* grid and the previews it opens fetch files by `folderId + name` only.
|
||||
*
|
||||
* Opened from a clicked folder path: when the server finds a FILE there after all,
|
||||
* it falls back to the file preview, i.e. what a click did before folders were links.
|
||||
*/
|
||||
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
|
||||
if (!sessionId || !folderPath) return;
|
||||
const overlay = this.$('folderGridOverlay');
|
||||
const body = this.$('folderGridBody');
|
||||
if (!overlay || !body) return;
|
||||
this._bindFolderGridKeys();
|
||||
let result = null;
|
||||
let status = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
status = res.status;
|
||||
result = await res.json().catch(() => null);
|
||||
} catch (err) {
|
||||
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (!result?.success || !result.data) {
|
||||
const reason = result?.error || `HTTP ${status}`;
|
||||
if (status === 400 && /not a folder/i.test(reason)) {
|
||||
this.openFilePreview(folderPath, sessionId);
|
||||
return;
|
||||
}
|
||||
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.folderGrid = { sessionId, ...result.data, index: null };
|
||||
this._renderFolderGrid();
|
||||
overlay.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
},
|
||||
|
||||
closeFolderGrid() {
|
||||
this.$('folderGridOverlay')?.classList.remove('visible');
|
||||
const body = this.$('folderGridBody');
|
||||
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
|
||||
if (body) body.innerHTML = '';
|
||||
this.folderGrid = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
folderGridUp() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.path === '/') return;
|
||||
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
|
||||
},
|
||||
|
||||
_renderFolderGrid() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid) return;
|
||||
const title = this.$('folderGridTitle');
|
||||
const foldersEl = this.$('folderGridFolders');
|
||||
const body = this.$('folderGridBody');
|
||||
const footer = this.$('folderGridFooter');
|
||||
title.textContent = grid.path;
|
||||
title.title = grid.path;
|
||||
const upBtn = this.$('folderGridUpBtn');
|
||||
if (upBtn) upBtn.disabled = grid.path === '/';
|
||||
|
||||
foldersEl.innerHTML = '';
|
||||
for (const name of grid.folders) {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'folder-grid-chip';
|
||||
chip.textContent = `${name}/`;
|
||||
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
|
||||
foldersEl.appendChild(chip);
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
if (grid.files.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'folder-grid-empty';
|
||||
empty.textContent = 'No previewable files in this folder.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
grid.files.forEach((file, index) => {
|
||||
const tile = document.createElement('button');
|
||||
tile.type = 'button';
|
||||
tile.className = 'folder-grid-tile';
|
||||
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
|
||||
tile.dataset.index = String(index);
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'folder-grid-thumb';
|
||||
const label = file.extension.toUpperCase();
|
||||
const route = this._folderFileUrl(grid, file.name, '');
|
||||
// Images load themselves (lazily — a folder can hold hundreds of renders);
|
||||
// PDF/Office show their first page where the server can render one (local
|
||||
// folders), everything else a type label.
|
||||
const src =
|
||||
file.attachmentType === 'image'
|
||||
? `${route}/raw`
|
||||
: ['pdf', 'docx', 'pptx'].includes(file.extension)
|
||||
? `${route}/thumbnail`
|
||||
: null;
|
||||
if (src) {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = file.name;
|
||||
img.src = src;
|
||||
img.onerror = () => {
|
||||
thumb.textContent = label;
|
||||
};
|
||||
thumb.appendChild(img);
|
||||
} else {
|
||||
thumb.textContent = label;
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
name.className = 'folder-grid-name';
|
||||
name.textContent = file.name;
|
||||
tile.append(thumb, name);
|
||||
tile.onclick = () => this._openFolderGridFile(index);
|
||||
body.appendChild(tile);
|
||||
});
|
||||
|
||||
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
|
||||
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
|
||||
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
|
||||
},
|
||||
|
||||
_folderFileUrl(grid, name, suffix) {
|
||||
return CodemanBase.url(
|
||||
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
|
||||
);
|
||||
},
|
||||
|
||||
_openFolderGridFile(index) {
|
||||
const grid = this.folderGrid;
|
||||
const file = grid?.files[index];
|
||||
if (!file) return;
|
||||
const body = this.$('folderGridBody');
|
||||
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
|
||||
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
|
||||
tile?.classList.add('current');
|
||||
tile?.scrollIntoView({ block: 'nearest' });
|
||||
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
|
||||
folderFile: { folderId: grid.folderId, name: file.name },
|
||||
size: file.size,
|
||||
});
|
||||
// After openFilePreview: it resets the index for previews from elsewhere.
|
||||
grid.index = index;
|
||||
grid.lastIndex = index;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
|
||||
stepFolderPreview(delta) {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.index === null || grid.files.length === 0) return;
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
const next = (grid.index + delta + grid.files.length) % grid.files.length;
|
||||
this._openFolderGridFile(next);
|
||||
},
|
||||
|
||||
/** "Show folder" in the preview: the grid of the previewed file's folder. */
|
||||
openFolderOfPreview() {
|
||||
const target = this.filePreviewFolderTarget;
|
||||
if (!target) return;
|
||||
this.closeFilePreview();
|
||||
this.openFolderGrid(target.dir, target.sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* The folder a previewed file lives in, or null when that is unknown — an
|
||||
* attachment card carries only a bare file name.
|
||||
*/
|
||||
_previewFolderPath(filePath, sessionId, attachmentId) {
|
||||
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
|
||||
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
|
||||
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
|
||||
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
|
||||
const cut = resolved.lastIndexOf('/');
|
||||
return { dir: resolved.slice(0, cut) || '/', sessionId };
|
||||
},
|
||||
|
||||
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
|
||||
_updateFolderPreviewNav(folderTarget) {
|
||||
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
|
||||
const prev = this.$('filePreviewPrevBtn');
|
||||
const next = this.$('filePreviewNextBtn');
|
||||
if (prev) prev.hidden = !browsing;
|
||||
if (next) next.hidden = !browsing;
|
||||
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
|
||||
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
|
||||
const folderBtn = this.$('filePreviewFolderBtn');
|
||||
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
|
||||
},
|
||||
|
||||
/**
|
||||
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
|
||||
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
|
||||
* overlays, and an arrow key that reached xterm would be typed into the session.
|
||||
*/
|
||||
_bindFolderGridKeys() {
|
||||
if (this._folderGridKeysBound) return;
|
||||
this._folderGridKeysBound = true;
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(ev) => {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
|
||||
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
|
||||
if (!gridOpen) return;
|
||||
const t = ev.target;
|
||||
const typing =
|
||||
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
|
||||
if (typing || this.filePreviewEdit) return;
|
||||
let handled = true;
|
||||
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
|
||||
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
|
||||
// Grid alone: an arrow opens the last viewed tile (or the first), so it
|
||||
// pages the folder instead of landing in the terminal behind the grid.
|
||||
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
|
||||
this._openFolderGridFile(grid.lastIndex ?? 0);
|
||||
else if (ev.key === 'Escape') {
|
||||
if (previewOpen) this.closeFilePreview();
|
||||
else this.closeFolderGrid();
|
||||
} else handled = false;
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
closeFilePreview() {
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
this._resetFilePreviewEdit();
|
||||
// Back on the grid (if one is open underneath): its current tile stays marked.
|
||||
if (this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
+128
-1
@@ -11375,6 +11375,132 @@ kbd {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Folder grid (panels-ui.js openFolderGrid): one step BELOW the file preview, so a
|
||||
tile opens its preview on top and closing that preview lands back on the grid. */
|
||||
.folder-grid-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--modal-backdrop);
|
||||
z-index: 5090;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.folder-grid-overlay.visible {
|
||||
display: flex;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.folder-grid-window {
|
||||
width: 92vw;
|
||||
max-width: 1400px;
|
||||
height: 88vh;
|
||||
background: var(--floating-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--elevated-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-grid-folders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 6.5rem;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-grid-folders:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-grid-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-grid-chip:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.folder-grid-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.folder-grid-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-grid-tile:hover,
|
||||
.folder-grid-tile.current {
|
||||
border-color: var(--accent, #4ea1ff);
|
||||
}
|
||||
|
||||
.folder-grid-thumb {
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.folder-grid-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.folder-grid-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.file-preview-window {
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
@@ -19386,7 +19512,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
|
||||
/* No gutter of their own. */
|
||||
.modal,
|
||||
.file-preview-overlay {
|
||||
.file-preview-overlay,
|
||||
.folder-grid-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
@@ -2079,7 +2079,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const addLink = (filePath, matchIndex, folder = false) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
@@ -2117,6 +2117,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// server knows that host's home, and the log viewer cannot ask).
|
||||
const sessionId = getSessionId();
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
// A folder path opens the folder grid (which falls back to the
|
||||
// preview if the server finds a file there after all).
|
||||
if (folder) {
|
||||
self.openFolderGrid(target, sessionId);
|
||||
return;
|
||||
}
|
||||
if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
@@ -2146,7 +2152,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
for (const found of findFilePathLinks(lineText)) {
|
||||
addLink(found.path, found.index);
|
||||
addLink(found.path, found.index, !!found.folder);
|
||||
}
|
||||
|
||||
bashPattern.lastIndex = 0;
|
||||
|
||||
+182
-28
@@ -30,12 +30,20 @@ import {
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import {
|
||||
FolderListingError,
|
||||
folderFilePath,
|
||||
folderGrants,
|
||||
listFolder,
|
||||
validFolderFileName,
|
||||
} from '../../folder-listing.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
@@ -426,6 +434,40 @@ async function resolveServableRemoteAttachment(
|
||||
return { path: probe.realPath, probe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
|
||||
* the attachment raw route, shared with the folder-grid file route.
|
||||
*
|
||||
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
|
||||
* contract, and its size comes from the guard's own re-probe — so serving a remote
|
||||
* file needs no stat the local branch would not also need.
|
||||
*/
|
||||
async function serveServableRaw(
|
||||
reply: FastifyReply,
|
||||
servable: ServableAttachment,
|
||||
remote: SessionRemote | undefined,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
query: { download?: string; preview?: string; range?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(extension, query, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
@@ -2263,34 +2305,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
});
|
||||
if (!servable) return;
|
||||
|
||||
try {
|
||||
// A remote record streams over ssh exactly like file-raw, with the same
|
||||
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
|
||||
// serving a remote attachment needs no stat the local branch would not also need.
|
||||
const remote = session.remote;
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(
|
||||
reply,
|
||||
target,
|
||||
size,
|
||||
record.fileName,
|
||||
record.extension,
|
||||
download === 'true',
|
||||
req.headers.range
|
||||
);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
@@ -2354,6 +2373,141 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
await serveThumbnail(reply, servable.path, record.extension);
|
||||
});
|
||||
|
||||
// ===== Folder grid =====
|
||||
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
|
||||
// grid. Same guard as a single external attachment; the grid then fetches files by
|
||||
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
|
||||
app.post('/api/sessions/:id/folders', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
const body = (req.body || {}) as { path?: unknown };
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const listing = await listFolder(id, body.path, { sessionWorkingDir: scope.workingDir, remote: scope.remote });
|
||||
return { success: true, data: listing };
|
||||
} catch (err) {
|
||||
if (err instanceof FolderListingError) {
|
||||
const code =
|
||||
err.statusCode === 404
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: err.statusCode === 502
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
|
||||
return;
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One file of a granted folder, guard-checked like an attachment: the name is a
|
||||
* bare file name of a supported type, and the joined path goes through the same
|
||||
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
|
||||
* re-resolution, remote re-probe) an attachment by id does.
|
||||
*/
|
||||
async function resolveFolderFile(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
|
||||
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return null;
|
||||
const grant = folderGrants.get(id, folderId);
|
||||
if (!grant) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
|
||||
return null;
|
||||
}
|
||||
const fileName = validFolderFileName(name);
|
||||
if (!fileName) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
|
||||
return null;
|
||||
}
|
||||
const filePath = folderFilePath(grant.dir, fileName);
|
||||
if (!scope.remote) {
|
||||
try {
|
||||
if (!(await fs.stat(filePath)).isFile()) throw new Error('not a file');
|
||||
} catch {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||
const record: AttachmentRecord = {
|
||||
attachmentId: '',
|
||||
sessionId: id,
|
||||
filePath,
|
||||
fileName,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
timestamp: 0,
|
||||
source: 'external',
|
||||
};
|
||||
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
||||
if (!servable) return null;
|
||||
return { scope, servable, name: fileName, extension };
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.extension !== 'docx' && file.extension !== 'pptx') {
|
||||
const { id, folderId } = req.params as { id: string; folderId: string };
|
||||
reply.redirect(
|
||||
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Office document preview is not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Thumbnails are not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveThumbnail(reply, file.servable.path, file.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { folderGrants } from '../folder-listing.js';
|
||||
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
@@ -1526,6 +1527,7 @@ export class WebServer extends EventEmitter {
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// Drop live external attachment registrations for this session
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
folderGrants.clearSession(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// Clean up pasted images directory for this session. The dir belongs to the
|
||||
@@ -3423,6 +3425,7 @@ export class WebServer extends EventEmitter {
|
||||
// otherwise meet the retry, which reuses the same session id by design.
|
||||
this.stopTranscriptWatcher(sessionId);
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
folderGrants.clearSession(sessionId);
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
||||
|
||||
@@ -298,7 +298,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
it('finds the overlays it is meant to guard', () => {
|
||||
// A rename that empties this list would turn every assertion below into a
|
||||
// no-op, so the count is pinned.
|
||||
expect(CENTRED_OVERLAYS.length).toBe(7);
|
||||
expect(CENTRED_OVERLAYS.length).toBe(8);
|
||||
});
|
||||
|
||||
it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => {
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @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('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,
|
||||
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('"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' });
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ const PATTERN_NAMES = [
|
||||
'cmdPattern',
|
||||
'FILE_PATH_LINK_PATTERN',
|
||||
'RELATIVE_FILE_PATH_LINK_PATTERN',
|
||||
'FOLDER_PATH_LINK_PATTERN',
|
||||
'bashPattern',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* @fileoverview Folder grid — `POST /api/sessions/:id/folders` grants + lists a
|
||||
* folder, `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)` serves one
|
||||
* of its files by name. Local folders are real temp dirs; a remote case mocks only
|
||||
* the ssh IO, and the remote listing script itself runs on a real `sh`.
|
||||
*
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { folderGrants, validFolderFileName } from '../../src/folder-listing.js';
|
||||
import type { SessionRemote } from '../../src/types/session.js';
|
||||
|
||||
vi.mock('../../src/remote-files.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
|
||||
return {
|
||||
...actual,
|
||||
remoteListFolder: vi.fn(),
|
||||
remoteProbePaths: vi.fn(),
|
||||
remoteCreateReadStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildRemoteFolderListCommand,
|
||||
parseRemoteFolderListOutput,
|
||||
remoteCreateReadStream,
|
||||
remoteListFolder,
|
||||
remoteProbePaths,
|
||||
} from '../../src/remote-files.js';
|
||||
|
||||
const mockedListFolder = vi.mocked(remoteListFolder);
|
||||
const mockedProbePaths = vi.mocked(remoteProbePaths);
|
||||
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
|
||||
|
||||
function touch(path: string, body: string, mtimeSec: number) {
|
||||
writeFileSync(path, body);
|
||||
utimesSync(path, mtimeSec, mtimeSec);
|
||||
}
|
||||
|
||||
describe('folder grid routes — local folder', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let sessionId: string;
|
||||
let root: string;
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
sessionId = harness.ctx._sessionId;
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-folder-grid-'));
|
||||
dir = join(root, 'renders');
|
||||
mkdirSync(dir);
|
||||
touch(join(dir, 'old.png'), 'old-png', 1_700_000_000);
|
||||
touch(join(dir, 'new.PNG'), 'new-png', 1_700_000_500);
|
||||
touch(join(dir, 'clip.mp4'), 'mp4', 1_700_000_100);
|
||||
touch(join(dir, 'notes.xyz'), 'unsupported', 1_700_000_900);
|
||||
touch(join(dir, '.hidden.png'), 'hidden', 1_700_000_900);
|
||||
mkdirSync(join(dir, 'shots'));
|
||||
mkdirSync(join(dir, '.cache'));
|
||||
harness.ctx._session.workingDir = root;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
folderGrants.clearSession(sessionId);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
|
||||
});
|
||||
|
||||
async function grant(path: string) {
|
||||
return harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path } });
|
||||
}
|
||||
|
||||
it('lists supported files newest first plus subfolders, skipping hidden + unsupported', async () => {
|
||||
const res = await grant(dir);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = JSON.parse(res.body).data;
|
||||
expect(data.folderId).toMatch(/^fld_/);
|
||||
expect(data.path).toBe(dir);
|
||||
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['new.PNG', 'clip.mp4', 'old.png']);
|
||||
expect(data.files[0]).toMatchObject({ extension: 'png', attachmentType: 'image', size: 7 });
|
||||
expect(data.folders).toEqual(['shots']);
|
||||
expect(data.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('grants the same folder the same id', async () => {
|
||||
const a = JSON.parse((await grant(dir)).body).data.folderId;
|
||||
const b = JSON.parse((await grant(`${dir}/`)).body).data.folderId;
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
|
||||
it('rejects a relative path, a file, and a missing folder', async () => {
|
||||
expect((await grant('renders')).statusCode).toBe(400);
|
||||
expect((await grant(join(dir, 'old.png'))).statusCode).toBe(400);
|
||||
expect((await grant(join(root, 'nope'))).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a blocked folder and a folder outside a confined workspace', async () => {
|
||||
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir;
|
||||
expect((await grant(dir)).statusCode).toBe(403);
|
||||
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
|
||||
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
harness.ctx._session.workingDir = join(dir, 'shots');
|
||||
expect((await grant(dir)).statusCode).toBe(403);
|
||||
harness.ctx._session.workingDir = root;
|
||||
expect((await grant(dir)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('serves a granted file by name, with ranges', async () => {
|
||||
const { folderId } = JSON.parse((await grant(dir)).body).data;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.body).toBe('old-png');
|
||||
|
||||
const ranged = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
|
||||
headers: { range: 'bytes=0-2' },
|
||||
});
|
||||
expect(ranged.statusCode).toBe(206);
|
||||
expect(ranged.body).toBe('old');
|
||||
});
|
||||
|
||||
it('redirects a non-office preview to raw', async () => {
|
||||
const { folderId } = JSON.parse((await grant(dir)).body).data;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/raw`);
|
||||
});
|
||||
|
||||
it('never serves a name that leaves the folder or has an unsupported type', async () => {
|
||||
const { folderId } = JSON.parse((await grant(dir)).body).data;
|
||||
writeFileSync(join(root, 'outside.png'), 'outside');
|
||||
for (const name of ['..%2Foutside.png', '..', '.', 'notes.xyz', 'shots%2Fx.png']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`,
|
||||
});
|
||||
expect(res.statusCode, name).not.toBe(200);
|
||||
}
|
||||
const missing = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/gone.png/raw`,
|
||||
});
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a file in the folder that symlinks to a blocked target', async () => {
|
||||
const secret = join(root, 'secret');
|
||||
mkdirSync(secret);
|
||||
writeFileSync(join(secret, 'key.png'), 'secret');
|
||||
symlinkSync(join(secret, 'key.png'), join(dir, 'innocent.png'));
|
||||
const { folderId } = JSON.parse((await grant(dir)).body).data;
|
||||
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = secret;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${folderId}/files/innocent.png/raw`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('404s an unknown folder id and another session’s grant', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/fld_nope/files/old.png/raw`,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
const foreign = folderGrants.grant('another-session', dir);
|
||||
const res2 = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${foreign}/files/old.png/raw`,
|
||||
});
|
||||
expect(res2.statusCode).toBe(404);
|
||||
folderGrants.clearSession('another-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder grid routes — remote (SSH) case', () => {
|
||||
const REMOTE_DIR = '/srv/remote/case';
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: REMOTE_DIR,
|
||||
};
|
||||
let harness: RouteTestHarness;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
sessionId = harness.ctx._sessionId;
|
||||
harness.ctx._session.workingDir = REMOTE_DIR;
|
||||
harness.ctx._session.remote = { ...remote };
|
||||
mockedListFolder.mockResolvedValue({
|
||||
dir: '/mnt/raid/styleframes',
|
||||
entries: [
|
||||
{ kind: 'file', name: 'a.png', size: 3, mtimeMs: 1000 },
|
||||
{ kind: 'file', name: 'b.jpg', size: 4, mtimeMs: 2000 },
|
||||
{ kind: 'directory', name: 'old', size: 0, mtimeMs: 0 },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
mockedProbePaths.mockResolvedValue([
|
||||
{ realPath: '/mnt/raid/styleframes/a.png', kind: 'file', size: 3, mtimeMs: 1000 },
|
||||
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
|
||||
]);
|
||||
mockedCreateReadStream.mockReturnValue({ stream: Readable.from([Buffer.from('abc')]), close: vi.fn() } as never);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
folderGrants.clearSession(sessionId);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists the folder on the remote host and serves its files over ssh', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/folders`,
|
||||
payload: { path: '~/styleframes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = JSON.parse(res.body).data;
|
||||
expect(mockedListFolder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: '192.0.2.10' }),
|
||||
'~/styleframes',
|
||||
expect.arrayContaining(['png', 'pdf']),
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(data.path).toBe('/mnt/raid/styleframes');
|
||||
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['b.jpg', 'a.png']);
|
||||
expect(data.folders).toEqual(['old']);
|
||||
expect(data.truncated).toBe(true);
|
||||
|
||||
const raw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/raw`,
|
||||
});
|
||||
expect(raw.statusCode).toBe(200);
|
||||
expect(raw.body).toBe('abc');
|
||||
expect(mockedProbePaths).toHaveBeenCalledWith(expect.anything(), ['/mnt/raid/styleframes/a.png', REMOTE_DIR]);
|
||||
|
||||
const thumb = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/thumbnail`,
|
||||
});
|
||||
expect(thumb.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 400 "Not a folder" for a remote file, so the client falls back to the preview', async () => {
|
||||
mockedListFolder.mockResolvedValue('not-a-folder');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/folders`,
|
||||
payload: { path: '/srv/remote/case/Makefile' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/not a folder/i);
|
||||
});
|
||||
|
||||
it('answers 404 for a missing remote folder', async () => {
|
||||
mockedListFolder.mockResolvedValue(null);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/folders`,
|
||||
payload: { path: '/nope' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote folder listing script (real sh)', () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-folder-sh-'));
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function run(dir: string, max = 100, home = root) {
|
||||
const stdout = execFileSync('sh', ['-c', buildRemoteFolderListCommand(dir, ['png', 'pdf'], max)], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: home },
|
||||
});
|
||||
return parseRemoteFolderListOutput(stdout);
|
||||
}
|
||||
|
||||
it('lists supported files with size/mtime, subfolders, odd names; resolves ~ and symlinks', () => {
|
||||
const dir = join(root, 'frames');
|
||||
mkdirSync(dir);
|
||||
touch(join(dir, "it's a | frame.PNG"), '12345', 1_700_000_000);
|
||||
touch(join(dir, 'doc.pdf'), 'pdf', 1_700_000_100);
|
||||
touch(join(dir, 'skip.txt'), 'x', 1_700_000_100);
|
||||
touch(join(dir, '.hidden.png'), 'x', 1_700_000_100);
|
||||
mkdirSync(join(dir, 'sub dir'));
|
||||
symlinkSync(dir, join(root, 'link'));
|
||||
|
||||
const listing = run('~/link');
|
||||
expect(listing?.dir).toBe(dir);
|
||||
expect(listing?.truncated).toBe(false);
|
||||
const byName = Object.fromEntries((listing?.entries ?? []).map((e) => [e.name, e]));
|
||||
expect(Object.keys(byName).sort()).toEqual(['doc.pdf', "it's a | frame.PNG", 'sub dir']);
|
||||
expect(byName["it's a | frame.PNG"]).toMatchObject({ kind: 'file', size: 5, mtimeMs: 1_700_000_000_000 });
|
||||
expect(byName['sub dir']).toMatchObject({ kind: 'directory' });
|
||||
});
|
||||
|
||||
it('caps entries; tells a missing folder (null) from a file', () => {
|
||||
for (let i = 0; i < 5; i++) touch(join(root, `f${i}.png`), 'x', 1_700_000_000);
|
||||
const capped = run(root, 3);
|
||||
expect(capped?.entries).toHaveLength(3);
|
||||
expect(capped?.truncated).toBe(true);
|
||||
expect(run(join(root, 'missing'))).toBeNull();
|
||||
expect(run(join(root, 'f0.png'))).toBe('not-a-folder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validFolderFileName', () => {
|
||||
it('accepts bare supported names only', () => {
|
||||
expect(validFolderFileName('a b.png')).toBe('a b.png');
|
||||
for (const bad of ['', '.', '..', 'a/b.png', 'a\\b.png', 'x.xyz', 'a\0.png', 42]) {
|
||||
expect(validFolderFileName(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -308,7 +308,7 @@ describe('callers consult the hook first', () => {
|
||||
|
||||
it('response viewer links route through the hook and keep the file-path handler first', () => {
|
||||
const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {');
|
||||
const section = APP_JS.slice(bind, bind + 2500);
|
||||
const section = APP_JS.slice(bind, bind + 3500);
|
||||
const pathHandler = section.indexOf("closest('a.rv-path')");
|
||||
const urlHandler = section.indexOf("closest('a[href]')");
|
||||
expect(pathHandler).toBeGreaterThan(-1);
|
||||
|
||||
Reference in New Issue
Block a user