mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
feat(preview): folder grid — browse every previewable file of a folder
Folder paths in the terminal and the response viewer are now links (rooted or ~/ without an extension, relative with a trailing slash); a click opens a grid of the folder's supported files, newest first, with subfolder chips and a parent button. A tile opens the file preview, which pages through the folder with ‹ › / ←/→; the preview of any file gets a "Show folder" button. Server: POST /api/sessions/:id/folders grants + lists a folder (local fs or one ssh round trip for a remote case) under the attachment guard; GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one file by bare name through resolveServableAttachmentPath, so browser requests still never carry a path. Grants live in their own small registry so a big folder cannot evict the session's attachments. The attachment raw route's body is now the shared serveServableRaw helper. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7d46a9d336
commit
1010d80b4f
16 files changed
+1706
-44
No files matched your search
+182
-28
@@ -30,12 +30,20 @@ import {
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import {
|
||||
FolderListingError,
|
||||
folderFilePath,
|
||||
folderGrants,
|
||||
listFolder,
|
||||
validFolderFileName,
|
||||
} from '../../folder-listing.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
@@ -426,6 +434,40 @@ async function resolveServableRemoteAttachment(
|
||||
return { path: probe.realPath, probe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
|
||||
* the attachment raw route, shared with the folder-grid file route.
|
||||
*
|
||||
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
|
||||
* contract, and its size comes from the guard's own re-probe — so serving a remote
|
||||
* file needs no stat the local branch would not also need.
|
||||
*/
|
||||
async function serveServableRaw(
|
||||
reply: FastifyReply,
|
||||
servable: ServableAttachment,
|
||||
remote: SessionRemote | undefined,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
query: { download?: string; preview?: string; range?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(extension, query, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
@@ -2263,34 +2305,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
});
|
||||
if (!servable) return;
|
||||
|
||||
try {
|
||||
// A remote record streams over ssh exactly like file-raw, with the same
|
||||
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
|
||||
// serving a remote attachment needs no stat the local branch would not also need.
|
||||
const remote = session.remote;
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(
|
||||
reply,
|
||||
target,
|
||||
size,
|
||||
record.fileName,
|
||||
record.extension,
|
||||
download === 'true',
|
||||
req.headers.range
|
||||
);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
@@ -2354,6 +2373,141 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
await serveThumbnail(reply, servable.path, record.extension);
|
||||
});
|
||||
|
||||
// ===== Folder grid =====
|
||||
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
|
||||
// grid. Same guard as a single external attachment; the grid then fetches files by
|
||||
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
|
||||
app.post('/api/sessions/:id/folders', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
const body = (req.body || {}) as { path?: unknown };
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const listing = await listFolder(id, body.path, { sessionWorkingDir: scope.workingDir, remote: scope.remote });
|
||||
return { success: true, data: listing };
|
||||
} catch (err) {
|
||||
if (err instanceof FolderListingError) {
|
||||
const code =
|
||||
err.statusCode === 404
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: err.statusCode === 502
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
|
||||
return;
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One file of a granted folder, guard-checked like an attachment: the name is a
|
||||
* bare file name of a supported type, and the joined path goes through the same
|
||||
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
|
||||
* re-resolution, remote re-probe) an attachment by id does.
|
||||
*/
|
||||
async function resolveFolderFile(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
|
||||
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return null;
|
||||
const grant = folderGrants.get(id, folderId);
|
||||
if (!grant) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
|
||||
return null;
|
||||
}
|
||||
const fileName = validFolderFileName(name);
|
||||
if (!fileName) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
|
||||
return null;
|
||||
}
|
||||
const filePath = folderFilePath(grant.dir, fileName);
|
||||
if (!scope.remote) {
|
||||
try {
|
||||
if (!(await fs.stat(filePath)).isFile()) throw new Error('not a file');
|
||||
} catch {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||
const record: AttachmentRecord = {
|
||||
attachmentId: '',
|
||||
sessionId: id,
|
||||
filePath,
|
||||
fileName,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
timestamp: 0,
|
||||
source: 'external',
|
||||
};
|
||||
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
||||
if (!servable) return null;
|
||||
return { scope, servable, name: fileName, extension };
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.extension !== 'docx' && file.extension !== 'pptx') {
|
||||
const { id, folderId } = req.params as { id: string; folderId: string };
|
||||
reply.redirect(
|
||||
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Office document preview is not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Thumbnails are not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveThumbnail(reply, file.servable.path, file.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
|
||||
Reference in new issue
Block a user