From 1010d80b4fead50ace0edd445eda3cf87aa0c43d Mon Sep 17 00:00:00 2001 From: Randalix Date: Sat, 10 Oct 2026 21:22:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(preview):=20folder=20grid=20=E2=80=94=20br?= =?UTF-8?q?owse=20every=20previewable=20file=20of=20a=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/attachment-registry.ts | 5 + src/folder-listing.ts | 239 +++++++++++++++++ src/remote-files.ts | 114 ++++++++ src/web/public/app.js | 6 +- src/web/public/constants.js | 44 +++- src/web/public/index.html | 19 ++ src/web/public/panels-ui.js | 277 +++++++++++++++++++- src/web/public/styles.css | 129 ++++++++- src/web/public/terminal-ui.js | 10 +- src/web/routes/file-routes.ts | 210 +++++++++++++-- src/web/server.ts | 3 + test/foldable-layout.test.ts | 2 +- test/folder-grid-ui.test.ts | 349 +++++++++++++++++++++++++ test/link-provider-regex.test.ts | 1 + test/routes/folder-grid-routes.test.ts | 340 ++++++++++++++++++++++++ test/webview-loopback-links.test.ts | 2 +- 16 files changed, 1706 insertions(+), 44 deletions(-) create mode 100644 src/folder-listing.ts create mode 100644 test/folder-grid-ui.test.ts create mode 100644 test/routes/folder-grid-routes.test.ts diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts index 4dfd6f9e..d64e7ce3 100644 --- a/src/attachment-registry.ts +++ b/src/attachment-registry.ts @@ -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(/^\./, '')); } diff --git a/src/folder-listing.ts b/src/folder-listing.ts new file mode 100644 index 00000000..a9cd0b20 --- /dev/null +++ b/src/folder-listing.ts @@ -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>(); + + 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 { + 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 }; +} diff --git a/src/remote-files.ts b/src/remote-files.ts index 393becee..ba5e20e6 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -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|`, `d|0|0|`, + * `f|||`, 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 { + 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 { assertNotUnderTest(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 4fe132ee..b51f951a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ae38421b..3a8f6750 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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. * diff --git a/src/web/public/index.html b/src/web/public/index.html index cb6d38d0..98e1e553 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -614,6 +614,9 @@
file.ts
+ + + @@ -634,6 +637,22 @@
+ +
+
+
+ +
+ + +
+
+
+
+ +
+
+
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 57fe70ff..5033affd 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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 = `${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 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'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 50229fdd..879f2505 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index afe24597..00e6e820 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index a74ece87..153ad376 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -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 { + 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) => { diff --git a/src/web/server.ts b/src/web/server.ts index bb159e16..65cb9659 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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'); diff --git a/test/foldable-layout.test.ts b/test/foldable-layout.test.ts index 3a6bfa60..e6db6320 100644 --- a/test/foldable-layout.test.ts +++ b/test/foldable-layout.test.ts @@ -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) => { diff --git a/test/folder-grid-ui.test.ts b/test/folder-grid-ui.test.ts new file mode 100644 index 00000000..95803303 --- /dev/null +++ b/test/folder-grid-ui.test.ts @@ -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; + 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(); + 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; + 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 = { + 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; + 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 }; +} + +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' }); + }); +}); diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts index e8962053..8375d50a 100644 --- a/test/link-provider-regex.test.ts +++ b/test/link-provider-regex.test.ts @@ -41,6 +41,7 @@ const PATTERN_NAMES = [ 'cmdPattern', 'FILE_PATH_LINK_PATTERN', 'RELATIVE_FILE_PATH_LINK_PATTERN', + 'FOLDER_PATH_LINK_PATTERN', 'bashPattern', ]; diff --git a/test/routes/folder-grid-routes.test.ts b/test/routes/folder-grid-routes.test.ts new file mode 100644 index 00000000..db360270 --- /dev/null +++ b/test/routes/folder-grid-routes.test.ts @@ -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(); + 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(); + } + }); +}); diff --git a/test/webview-loopback-links.test.ts b/test/webview-loopback-links.test.ts index 0208be9e..c19d3086 100644 --- a/test/webview-loopback-links.test.ts +++ b/test/webview-loopback-links.test.ts @@ -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);