feat(preview): folder grid — browse every previewable file of a folder

Folder paths in the terminal and the response viewer are now links
(rooted or ~/ without an extension, relative with a trailing slash); a
click opens a grid of the folder's supported files, newest first, with
subfolder chips and a parent button. A tile opens the file preview,
which pages through the folder with ‹ › / ←/→; the preview of any file
gets a "Show folder" button.

Server: POST /api/sessions/:id/folders grants + lists a folder (local
fs or one ssh round trip for a remote case) under the attachment guard;
GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one
file by bare name through resolveServableAttachmentPath, so browser
requests still never carry a path. Grants live in their own small
registry so a big folder cannot evict the session's attachments. The
attachment raw route's body is now the shared serveServableRaw helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-10 21:54:37 +02:00
1 parent 7d46a9d336
commit 1010d80b4f
16 files changed
+1706 -44

No files matched your search

+182 -28
View File
@@ -30,12 +30,20 @@ import {
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -426,6 +434,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe };
}
/**
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
* the attachment raw route, shared with the folder-grid file route.
*
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
* contract, and its size comes from the guard's own re-probe — so serving a remote
* file needs no stat the local branch would not also need.
*/
async function serveServableRaw(
reply: FastifyReply,
servable: ServableAttachment,
remote: SessionRemote | undefined,
fileName: string,
extension: string,
query: { download?: string; preview?: string; range?: string }
): Promise<void> {
try {
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(extension, query, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -2263,34 +2305,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
});
if (!servable) return;
try {
// A remote record streams over ssh exactly like file-raw, with the same
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
// serving a remote attachment needs no stat the local branch would not also need.
const remote = session.remote;
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
download,
preview,
range: req.headers.range,
});
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
@@ -2354,6 +2373,141 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveThumbnail(reply, servable.path, record.extension);
});
// ===== Folder grid =====
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
// grid. Same guard as a single external attachment; the grid then fetches files by
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
app.post('/api/sessions/:id/folders', async (req, reply) => {
const { id } = req.params as { id: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const body = (req.body || {}) as { path?: unknown };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
return;
}
try {
const listing = await listFolder(id, body.path, { sessionWorkingDir: scope.workingDir, remote: scope.remote });
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode === 502
? ApiErrorCode.OPERATION_FAILED
: ApiErrorCode.INVALID_INPUT;
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
return;
}
return reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
}
});
/**
* One file of a granted folder, guard-checked like an attachment: the name is a
* bare file name of a supported type, and the joined path goes through the same
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
* re-resolution, remote re-probe) an attachment by id does.
*/
async function resolveFolderFile(
req: FastifyRequest,
reply: FastifyReply
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return null;
const grant = folderGrants.get(id, folderId);
if (!grant) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
return null;
}
const fileName = validFolderFileName(name);
if (!fileName) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
return null;
}
const filePath = folderFilePath(grant.dir, fileName);
if (!scope.remote) {
try {
if (!(await fs.stat(filePath)).isFile()) throw new Error('not a file');
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return null;
}
}
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const record: AttachmentRecord = {
attachmentId: '',
sessionId: id,
filePath,
fileName,
extension,
attachmentType: getAttachmentType(extension),
size: 0,
mtimeMs: 0,
timestamp: 0,
source: 'external',
};
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return null;
return { scope, servable, name: fileName, extension };
}
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
const { download, preview } = req.query as { download?: string; preview?: string };
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
download,
preview,
range: req.headers.range,
});
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.extension !== 'docx' && file.extension !== 'pptx') {
const { id, folderId } = req.params as { id: string; folderId: string };
reply.redirect(
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
);
return;
}
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, file.servable.path, file.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {