Merge pull request #604: feat(preview): open ~/, relative and hard-wrapped paths; folder grid

This commit is contained in:
Codeman maintainer committed 2026-10-13 18:08:12 +02:00
commit 90034d07d7
25 files changed
+2949 -87

No files matched your search

+15 -3
View File
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { basename, extname, isAbsolute, join } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
@@ -148,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(/^\./, ''));
}
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
// `~/…` is the home of the session's HOST: expanded here for a local session,
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
// whose home this server does not know. A clicked terminal link sends it as is
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
// resolved absolute path either way.
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath));
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
+284
View File
@@ -0,0 +1,284 @@
/**
* @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 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 { isMultiUserMode, userSpacePath } from './config/multiuser.js';
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
import type { AttachmentDetectedType, AuthUser } from './types.js';
import type { SessionRemote } from './types/session.js';
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.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 hidden (which also rules out `.`/`..`; the listing leaves hidden
* entries out, so the routes do too), 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.startsWith('.') || 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;
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
user?: AuthUser;
}
/**
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
* a regular user reaches only their own space (`userSpacePath`) or the session's
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
* and read another user's space. Always true in single-user mode and for admins; a
* remote session's folders are on its host, where only the `workingDir` applies.
*
* @param path Already resolved (realpath on the session's host).
*/
export function folderAllowedForUser(
path: string,
user: AuthUser | undefined,
scope: { workingDir?: string; remote?: SessionRemote }
): boolean {
if (!isMultiUserMode()) return true;
if (!user) return false;
if (user.role === 'admin') return true;
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
return [userSpacePath(user.username), scope.workingDir].some(
(root) => !!root && !!validateSessionFilePath(root, path)
);
}
/**
* Resolve, guard, list and grant a folder for `sessionId`.
*
* A local folder is probed bounded first (`probePathKind`): one on a network mount
* that stopped answering must not freeze the server, and "did not answer" is never
* reported as "not found". The guard runs on the resolved folder before it is read.
*
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
*/
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');
}
const guard = await loadAttachmentGuardConfig();
const workingDir = options.sessionWorkingDir;
const assertAllowed = (dir: string): void => {
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);
}
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
throw new FolderListingError('Folder is outside your space', 403);
}
};
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);
// One ssh call lists and resolves together, so the guard can only run after it;
// nothing of a refused listing leaves this function.
assertAllowed(dir);
} else {
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
const kind = await probePathKind(expanded, { pastCap: true });
if (kind === 'unknown') {
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
}
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
if (kind !== 'directory') throw new FolderListingError('Not a folder');
try {
dir = await fs.realpath(expanded);
} catch {
throw new FolderListingError('Folder not found', 404);
}
assertAllowed(dir);
({ entries, truncated } = await listLocalFolder(dir));
}
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 };
}
+126
View File
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
* `echo` (which land before the script runs) from the records without any "last N
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
*
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
* server does not know that home, and a clicked `~/…` link means the host the
* session runs on. The record's `realPath` is absolute, as for any other path.
*
* Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
'probe() {',
' i=$1',
' p=$2',
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
@@ -269,6 +275,126 @@ 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` (any 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 {
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
.join('|');
const script = [
`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');
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
// file — the same wrapper the transcript read uses.
return `sh -c ${shellescape(script)}`;
}
/**
* 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();
+18 -13
View File
@@ -2751,7 +2751,13 @@ class CodemanApp {
const filePath = pathLink.dataset.path;
// A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
// A path the linkifier found in prose may be relative: resolve it against
// that session's working directory. A markdown document's own links
// (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 && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return;
}
@@ -2869,7 +2875,8 @@ class CodemanApp {
}
/**
* Make absolute file paths in a rendered message clickable.
* Make file paths in a rendered message clickable: absolute, `~/` and
* relative ones (`findFilePathLinks`, constants.js).
*
* The terminal's link provider never sees these: the response viewer is
* markdown, and a path the agent wrote as prose or inline code renders as
@@ -2887,8 +2894,7 @@ class CodemanApp {
if (!root || typeof document === 'undefined') return;
// Guarded: a stale cached constants.js must degrade to plain text, not throw
// out of the middle of rendering a message.
if (typeof absoluteFilePathPattern !== 'function') return;
const pattern = absoluteFilePathPattern();
if (typeof findFilePathLinks !== 'function') return;
// Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal.
@@ -2896,27 +2902,26 @@ class CodemanApp {
const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
pattern.lastIndex = 0;
if (pattern.test(node.nodeValue || '')) targets.push(node);
const found = findFilePathLinks(node.nodeValue || '');
if (found.length > 0) targets.push([node, found]);
}
for (const node of targets) {
for (const [node, found] of targets) {
const value = node.nodeValue;
const frag = document.createDocumentFragment();
let cursor = 0;
let match;
pattern.lastIndex = 0;
while ((match = pattern.exec(value)) !== null) {
const path = match[1];
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
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);
cursor = match.index + path.length;
cursor = index + path.length;
}
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
node.parentNode?.replaceChild(frag, node);
+268 -2
View File
@@ -2065,6 +2065,138 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext);
}
/**
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
* or a bare `look_montage.png`. Same extension list and body as
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
* equal).
*
* Without a known root to anchor on, the START is what has to be unambiguous:
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
* a URL. The path itself is group 2. Its first character is never `/`: absolute
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
* the file, not the punctuation around it.
*
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* 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, 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 || '');
const found = [];
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
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. A folder path ends at `:`,
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
* the `://` lookahead stops at the next colon instead of rescanning the token from
* every one (quadratic on `a:a:a:…//`).
*
* ⚠ 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.
*
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
* session's HOST, which only the server knows (a remote case's home is on the
* remote machine), so the server expands it. A relative path is resolved
* against the session's working directory, `.` and `..` collapsed, so a
* `../other/x.png` ends up outside the workspace and routes like any other
* external path. Without a working directory it is returned as is.
*
* @param {string} filePath
* @param {string} [workingDir]
* @returns {string}
*/
function resolveLinkedFilePath(filePath, workingDir) {
const path = String(filePath || '');
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
const parts = [];
for (const part of `${workingDir}/${path}`.split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return `/${parts.join('/')}`;
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -2169,6 +2301,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
return { startRow, endRow, text, offsetToCell, cellToOffset };
}
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
/**
* Whether the line `nextText` carries on a file path `prevText` broke off.
*
* Answers `'join'` when the path ends (reaches a known extension) on the next
* line, `'chain'` when the next line is nothing but more of the path (a path
* spanning three or more lines; only worth keeping once a later line ends it),
* and `null` otherwise. The broken-off token must already look like a path in
* progress: it contains a `/`, ends on a path character and does not yet end
* on an extension. A next line that opens a NEW path is never glued on: a rooted
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const next = String(nextText || '').replace(/^\s+/, '');
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
return head === next.replace(/\s+$/, '') ? 'chain' : null;
}
/**
* {@link terminalLogicalLine}, extended across the line breaks a program puts
* INSIDE a file path. Used by the link provider only; touch selection keeps the
* plain logical line.
*
* Claude Code wraps its tool output itself, a few columns short of the terminal
* edge, so neither continuation signal of terminalLogicalLine fires (no
* `isWrapped`, the row does not reach the last column) and a long path was only
* linked up to the break:
*
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
* 026-10-10/ghost1b_gameplay.png err=0 …
*
* A following logical line is glued on (its indent dropped, cell mapping kept
* exact) when {@link pathContinuation} says it carries the path on and the row it
* broke off is at least as long as the row it continues on. Bounded by the same
* `maxRows` as the logical line.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
function terminalPathLine(buffer, row, cols, maxRows) {
const base = terminalLogicalLine(buffer, row, cols, maxRows);
if (!base) return null;
const bound = Math.max(1, maxRows || 12);
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
const rowsOf = (line) => line.endRow - line.startRow + 1;
// A program breaking a long token fills the row to its wrap width, and no
// later row of that block is wider. So the row a path broke off is at least as
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
// to a longer ` file.png written` below it.
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
const continues = (prev, next, prevText) =>
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
const extend = (first) => {
const parts = [first];
let text = first.text;
let rows = rowsOf(first);
let confirmed = 1;
for (;;) {
const next = lineAt(parts[parts.length - 1].endRow + 1);
if (!next || rows + rowsOf(next) > bound) break;
const kind = continues(parts[parts.length - 1], next, text);
if (!kind) break;
parts.push(next);
text += next.text.replace(/^\s+/, '');
rows += rowsOf(next);
if (kind === 'join') confirmed = parts.length;
}
return parts.slice(0, confirmed);
};
// The hovered row may be the second or third line of a broken path: walk back
// to where it could start, then let the forward pass decide what really joins.
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
first = prev;
rows += rowsOf(prev);
}
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
if (parts.length === 1) return parts[0];
const segments = [];
let text = '';
for (const [i, part] of parts.entries()) {
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
segments.push({ part, textStart: text.length, skip });
text += part.text.slice(skip);
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
}
return segments[0].part.offsetToCell(offset);
};
const cellToOffset = (targetRow, targetCol) => {
for (const seg of segments) {
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
const offset = seg.part.cellToOffset(targetRow, targetCol);
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
}
return -1;
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
@@ -3081,8 +3341,14 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanFilePaths = {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
+19
View File
@@ -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>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</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">&uarr;</button>
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">&times;</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">
+294 -8
View File
@@ -4037,6 +4037,9 @@ Object.assign(CodemanApp.prototype, {
* unknown workingDir answers false, leaving the historical path untouched.
*/
_isExternalPreviewPath(filePath, sessionId) {
// `~/…` (a terminal or response-viewer link): only the server knows the home
// of the session's host, and only the attachment route expands it.
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false;
@@ -4083,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');
@@ -4093,6 +4107,12 @@ Object.assign(CodemanApp.prototype, {
if (!overlay || !bodyEl) return;
// Only the newest load may write: paging ←/→ through a folder starts the next
// load while the previous one still awaits its fetch, and its late answer would
// put the previous file's text under the next file's title.
const load = (this._filePreviewLoad = (this._filePreviewLoad || 0) + 1);
const stale = () => load !== this._filePreviewLoad;
// Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML
@@ -4122,8 +4142,9 @@ 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);
if (stale()) return;
attachmentId = external.attachmentId || null;
externalError = external.error || '';
externalSize = external.size || 0;
@@ -4140,9 +4161,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)}`
@@ -4152,8 +4178,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
@@ -4163,6 +4190,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)}">`;
@@ -4190,6 +4218,7 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
if (stale()) return;
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n');
// Markdown keeps every line the Range read returned: the byte bound is
@@ -4201,13 +4230,14 @@ 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';
footerEl.textContent = `${footerEl.textContent} (${note})`;
}
} catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
@@ -4240,11 +4270,14 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image');
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
const svgText = await res.text();
if (stale()) return;
const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
return;
@@ -4260,6 +4293,7 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
if (stale()) return;
if (!result.success) throw new Error(result.error || 'Failed to load file');
const data = result.data;
@@ -4299,14 +4333,266 @@ Object.assign(CodemanApp.prototype, {
}
}
} catch (err) {
if (stale()) return;
console.error('Failed to preview file:', err);
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
},
// ===== 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();
// Two quick opens (a chip, then its parent): only the newer one may render.
const load = (this._folderGridLoad = (this._folderGridLoad || 0) + 1);
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) {
if (load !== this._folderGridLoad) return;
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
return;
}
if (load !== this._folderGridLoad) 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() {
// A listing still on its way must not reopen the grid that was just closed.
this._folderGridLoad = (this._folderGridLoad || 0) + 1;
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
View File
@@ -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);
}
+48 -15
View File
@@ -2017,6 +2017,17 @@ Object.assign(CodemanApp.prototype, {
});
},
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
},
/**
* Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable.
@@ -2069,10 +2080,14 @@ Object.assign(CodemanApp.prototype, {
// both continuation kinds, the indent a hard wrap leaves on its
// continuation, and the offset↔cell mapping — because touch selection
// measures the SAME lines and the two must not disagree.
// `terminalPathLine` adds one thing on top, for links only: a path the
// program broke over lines itself (Claude Code wraps tool output short of
// the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png`
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
const logical = window.CodemanTerminalLines?.terminalPathLine(
buffer,
bufferLineNumber - 1,
terminal.cols,
@@ -2090,12 +2105,18 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 };
};
if (!lineText || !lineText.includes('/')) {
// A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
callback(undefined);
return;
}
const links = [];
// Text offsets of every link so far: a later pattern must not cut a
// second, overlapping link out of one already found (a relative `b.png`
// inside a URL's `?img=b.png`).
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority
//
@@ -2115,6 +2136,7 @@ Object.assign(CodemanApp.prototype, {
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
links.push({
text: cleaned,
@@ -2144,27 +2166,28 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
// Pattern 2: Paths with common extensions — absolute, `~/` and relative
// (`findFilePathLinks`, constants.js). Image/PDF/media extensions are
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink).
//
// The literal lives in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers. A fresh
// instance per call: `lastIndex` is per-object state.
const extPattern = absoluteFilePathPattern();
// The patterns live in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers.
// 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;
const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length);
// Skip if already have link at this position
// Skip if already have link at this position, or one overlapping it
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
if (isTaken(startCol, filePath.length)) return;
taken.push({ start: startCol, end: startCol + filePath.length });
links.push({
text: filePath,
@@ -2187,12 +2210,23 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
self.openFilePreview(text, sessionId);
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;
}
self.openLogViewerWindow(text, sessionId);
if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
},
hover() {
setHovered(true);
@@ -2216,9 +2250,8 @@ Object.assign(CodemanApp.prototype, {
addLink(match[2], match.index);
}
extPattern.lastIndex = 0;
while ((match = extPattern.exec(lineText)) !== null) {
addLink(match[1], match.index);
for (const found of findFilePathLinks(lineText)) {
addLink(found.path, found.index, !!found.folder);
}
bashPattern.lastIndex = 0;
+9
View File
@@ -17,6 +17,15 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
/**
* Longest path PARAMETER the router accepts (Fastify's default is 100). A file
* name goes up to 255 characters and arrives percent-encoded, so the folder
* grid's `…/files/:name/raw` answered 404 "Route not found" for a name the
* listing happily showed. Raw length, not decoded: 255 four-byte characters are
* 3060 characters of `%XX`.
*/
export const MAX_ROUTE_PARAM_LENGTH = 4096;
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
+242 -30
View File
@@ -23,6 +23,7 @@ import type {
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { compileFileQuery } from '../../utils/file-query.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AUDIO_ATTACHMENT_EXTENSIONS,
@@ -30,12 +31,21 @@ import {
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
folderAllowedForUser,
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 +436,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
@@ -520,8 +564,20 @@ function getKnownSessionWorkingDir(
interface SessionFileScope {
workingDir: string;
remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
}
/**
* A Docker case is a LOCAL session for the file routes (the workspace is a
* bind mount, so its paths are host paths), but `~` inside the container is the
* container's own seeded home, not this host's. Expanding `~/x.png` against the
* host's home would open a different file than the one the agent printed, so
* the form is refused instead of guessed.
*/
const DOCKER_HOME_PATH_ERROR =
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
/**
* The file-access scope of a session, live or persisted — the `workingDir`-only
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
@@ -539,12 +595,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
}
const stored = ctx.store.getSession(sessionId);
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
return { workingDir: stored.workingDir, remote: stored.remote };
return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
}
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
@@ -2143,6 +2199,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
if (session.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
// A remote case registers a path that lives on the REMOTE host: the guard and
@@ -2263,34 +2323,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 +2391,181 @@ 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;
}
if (scope.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
const listing = await listFolder(id, body.path, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
user: getAuthUser(req),
});
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode >= 500
? 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. The multi-user rule
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
* the granted folder must not lead into another user's space.
*/
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) {
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
const kind = await probePathKind(filePath, { pastCap: true });
if (kind === 'unknown') {
reply
.code(503)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
);
return null;
}
if (kind !== 'file') {
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;
// The requested NAME only opens the door; registration judges the file a
// symlink resolves to (supported extension, regular file) and so does this.
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
// behind a supported name parks a worker on an open that never returns.
if (!isSupportedAttachmentExtension(extname(servable.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
return null;
}
const isRegularFile = scope.remote
? servable.probe?.kind === 'file'
: await fs.stat(servable.path).then(
(stat) => stat.isFile(),
() => false
);
if (!isRegularFile) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
return null;
}
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
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) => {
+11 -2
View File
@@ -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';
@@ -158,6 +159,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -432,9 +434,14 @@ export class WebServer extends EventEmitter {
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || ''));
if (https) {
const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
this.app = Fastify({
logger: false,
https: { key, cert },
rewriteUrl,
routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH },
});
} else {
this.app = Fastify({ logger: false, rewriteUrl });
this.app = Fastify({ logger: false, rewriteUrl, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
}
this.mux = createMultiplexer();
this.sse = new SseStreamManager(
@@ -1526,6 +1533,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
@@ -3436,6 +3444,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');