FN-7073: serve file previews inline

File viewer previews now request inline-safe responses while downloads remain attachments.

- Add an inline preview query option to file download URL helpers and preview consumers.
- Serve known media and PDF preview extensions with real MIME types, inline disposition, nosniff, and sandbox CSP headers.
- Keep explicit downloads and unknown file types on attachment/octet-stream behavior.
- Cover preview and download header behavior with dashboard route and component tests.

Files changed:
 .changeset/fn-7073-file-viewer-inline-preview.md   |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/api/legacy.ts               |   9 +-
 .../dashboard/app/components/DockFilesView.tsx     |   2 +-
 .../dashboard/app/components/FileBrowserModal.tsx  |   2 +-
 .../components/__tests__/DockFilesView.test.tsx    |  14 +-
 .../components/__tests__/FileBrowserModal.test.tsx |  12 +-
 .../register-file-workspace-routes.test.ts         | 141 +++++++++++++++++++++
 .../src/routes/register-file-workspace-routes.ts   | 111 +++++++++++-----
 9 files changed, 260 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-7073
Fusion-Task-Lineage: 922b4aa4-ac6d-4a59-b94f-bab4eeb65530
This commit is contained in:
gsxdsm
2026-06-26 14:33:58 -07:00
parent 4f01c4dba1
commit 7137e36ccb
9 changed files with 259 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Files viewer previews for images, video, audio, and PDFs.
category: fix
dev: Preview URLs request inline file responses with safe MIME, nosniff, and sandbox CSP headers while downloads remain attachments.

View File

@@ -681,7 +681,7 @@ The Files modal provides a workspace-aware file browser and editor.
- Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation
- Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter
- The line-number preference is saved per project and restored automatically when you switch projects
- Known image, video/movie, audio, and PDF files render browser-native read-only previews from the selected project or task workspace download URL; text files remain editable, and unknown binary files keep the read-only editor fallback
- Known image, video/movie, audio, and PDF files render browser-native read-only previews inline with their real content type from the selected project or task workspace download URL; the explicit **Download** action still saves files as attachments, text files remain editable, and unknown binary files keep the read-only editor fallback
- In editable files and markdown preview mode, highlighted text exposes **Add comment** so you can send the file path, selected snippet, best-effort line range, and your note to the **New Task** dialog without copy/paste
## Memory View

View File

@@ -3511,11 +3511,18 @@ export function renameFile(workspace: string, filePath: string, newName: string,
}
/** Get the download URL for a single file in a workspace. */
export function downloadFileUrl(workspace: string, filePath: string, projectId?: string): string {
export function downloadFileUrl(workspace: string, filePath: string, projectId?: string, options?: { inline?: boolean }): string {
const query = new URLSearchParams({ workspace });
if (projectId) {
query.set("projectId", projectId);
}
/**
* FNXC:FileBrowser 2026-06-26-00:00:
* Browser-native preview consumers request `inline=1` so the shared download route serves renderable MIME types with inline disposition. The explicit Download action intentionally omits this option to preserve attachment downloads.
*/
if (options?.inline === true) {
query.set("inline", "1");
}
return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`;
}

View File

@@ -111,7 +111,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
if (!selectedFile || !selectedPreviewKind) {
return null;
}
return downloadFileUrl("project", selectedFile, projectId);
return downloadFileUrl("project", selectedFile, projectId, { inline: true });
}, [projectId, selectedFile, selectedPreviewKind]);
/*

View File

@@ -327,7 +327,7 @@ export function FileBrowserModal({
*/
const previewUrl = useMemo(() => {
if (!selectedFile || !selectedPreviewKind) return null;
return downloadFileUrl(currentWorkspace, selectedFile, projectId);
return downloadFileUrl(currentWorkspace, selectedFile, projectId, { inline: true });
}, [currentWorkspace, projectId, selectedFile, selectedPreviewKind]);
const selectedPreviewLabel = selectedFile

View File

@@ -47,15 +47,16 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string) => {
const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string, options?: { inline?: boolean }) => {
const params = new URLSearchParams({ workspace });
if (projectId) params.set("projectId", projectId);
return `/api/files/${encodeURIComponent(filePath)}?${params.toString()}`;
if (options?.inline) params.set("inline", "1");
return `/api/files/${encodeURIComponent(filePath)}/download?${params.toString()}`;
});
vi.mock("../../api", () => ({
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])),
downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined])),
downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined, { inline?: boolean } | undefined])),
}));
const capturedEditorHookCalls: Array<{
@@ -284,7 +285,7 @@ describe("DockFilesView shared current-file state", () => {
await waitFor(() => expect(document.querySelector(selector)).toBeInTheDocument());
const preview = document.querySelector(selector);
expect(preview).toHaveAttribute(attr, `/api/files/${encodeURIComponent(file)}?workspace=project&projectId=${PROJECT_ID}`);
expect(preview).toHaveAttribute(attr, `/api/files/${encodeURIComponent(file)}/download?workspace=project&projectId=${PROJECT_ID}&inline=1`);
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${file}`);
@@ -295,7 +296,7 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.queryByTestId("mock-file-editor")).toBeNull();
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: file, enabled: false, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).toHaveBeenLastCalledWith("project", file, PROJECT_ID);
expect(mockDownloadFileUrl).toHaveBeenLastCalledWith("project", file, PROJECT_ID, { inline: true });
});
it("keeps text files editable with Save when changes exist", async () => {
@@ -306,6 +307,7 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.getByTestId("right-dock-files-save")).toBeEnabled();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "changed.txt", enabled: true, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "changed.txt", PROJECT_ID, { inline: true });
});
it("keeps known non-preview binary files read-only without rendering garbage editor content", () => {
@@ -317,12 +319,14 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.queryByTestId("right-dock-files-save")).toBeNull();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "build/output.bin", enabled: false, projectId: PROJECT_ID });
expect(mockDownloadFileUrl).not.toHaveBeenCalledWith("project", "build/output.bin", PROJECT_ID, { inline: true });
});
it("clears stale preview state when switching from a preview file back to text", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("assets/Logo.PNG"));
await waitFor(() => expect(document.querySelector("img.file-browser-preview-media--image")).toBeInTheDocument());
expect(document.querySelector("img.file-browser-preview-media--image")).toHaveAttribute("src", expect.stringContaining("inline=1"));
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));

View File

@@ -825,6 +825,7 @@ describe("FileBrowserModal", () => {
expect(preview).toBeInTheDocument();
expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name)));
expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project"));
expect(preview).toHaveAttribute(attribute, expect.stringContaining("inline=1"));
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`);
@@ -858,6 +859,7 @@ describe("FileBrowserModal", () => {
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("src", expect.stringContaining("workspace=FN-001"));
expect(video).toHaveAttribute("src", expect.stringContaining("projectId=proj-1"));
expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(video).toHaveAttribute("src", expect.stringContaining("movie.mov"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1");
});
@@ -875,6 +877,7 @@ describe("FileBrowserModal", () => {
await waitFor(() => expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument());
const pdf = document.querySelector("iframe.file-browser-preview-media--pdf");
expect(pdf).toHaveAttribute("src", expect.stringContaining(encodeURIComponent("docs/MANUAL.PDF")));
expect(pdf).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF");
expect(mockSetPath).toHaveBeenCalledWith("docs");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
@@ -936,7 +939,10 @@ describe("FileBrowserModal", () => {
await selectFile("clip.mp4");
expect(document.querySelector("iframe.file-browser-preview-media--pdf")).not.toBeInTheDocument();
expect(document.querySelector("video.file-browser-preview-media--video")).toBeInTheDocument();
const video = document.querySelector("video.file-browser-preview-media--video");
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("src", expect.stringContaining("clip.mp4"));
expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined);
});
@@ -955,7 +961,9 @@ describe("FileBrowserModal", () => {
await selectFile("voice.mp3");
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
expect(document.querySelector("audio.file-browser-preview-media--audio")).toBeInTheDocument();
const audio = document.querySelector("audio.file-browser-preview-media--audio");
expect(audio).toBeInTheDocument();
expect(audio).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(document.querySelector(".file-browser-content.mobile.active")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,141 @@
// @vitest-environment node
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerFileWorkspaceRoutes } from "../register-file-workspace-routes.js";
import type { ApiRoutesContext } from "../types.js";
import { request as REQUEST } from "../../test-request.js";
const tempRoots: string[] = [];
async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "fusion-file-workspace-routes-"));
tempRoots.push(root);
return root;
}
function makeApp(store: Partial<TaskStore>) {
const router = express.Router();
const app = express();
registerFileWorkspaceRoutes({
router,
store: store as TaskStore,
runtimeLogger: {} as never,
planningLogger: {} as never,
chatLogger: {} as never,
getProjectIdFromRequest: vi.fn(),
getScopedStore: vi.fn(async () => store as TaskStore),
getProjectContext: vi.fn(async () => ({ store: store as TaskStore, engine: undefined, projectId: undefined })),
prioritizeProjectsForCurrentDirectory: vi.fn((projects) => projects),
emitRemoteRouteDiagnostic: vi.fn(),
emitAuthSyncAuditLog: vi.fn(),
parseScopeParam: vi.fn(),
resolveAutomationStore: vi.fn(),
resolveRoutineStore: vi.fn(),
resolveRoutineRunner: vi.fn(),
registerDispose: vi.fn(),
dispose: vi.fn(),
rethrowAsApiError(error: unknown, fallbackMessage?: string): never {
throw error instanceof Error ? error : new Error(fallbackMessage ?? String(error));
},
} as ApiRoutesContext);
app.use("/api", router);
return app;
}
async function writeFixture(root: string, filePath: string, content = "fixture-bytes"): Promise<void> {
const pathParts = filePath.split("/");
const fileName = pathParts.pop();
if (!fileName) {
throw new Error(`Fixture path must include a file name: ${filePath}`);
}
const directoryPath = join(root, ...pathParts);
await mkdir(directoryPath, { recursive: true });
await writeFile(join(directoryPath, fileName), Buffer.from(content));
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("file workspace download route", () => {
it.each([
["assets/logo.png", "image/png"],
["icons/mark.svg", "image/svg+xml"],
["media/demo.mp4", "video/mp4"],
["audio/theme.mp3", "audio/mpeg"],
["docs/spec.pdf", "application/pdf"],
["nested/CAPTURE.PNG", "image/png"],
])("serves previewable file %s inline with renderable headers", async (filePath, expectedContentType) => {
const root = await makeRoot();
await writeFixture(root, filePath);
const app = makeApp({ getRootDir: vi.fn(() => root) });
const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent(filePath)}/download?workspace=project&inline=1`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe(expectedContentType);
expect(res.headers["content-disposition"]).toBe(`inline; filename="${filePath.split("/").at(-1)}"`);
expect(res.headers["x-content-type-options"]).toBe("nosniff");
expect(res.headers["content-security-policy"]).toBe("sandbox");
expect(res.body).toBe("fixture-bytes");
});
it.each([
"assets/logo.png",
"icons/mark.svg",
"media/demo.mp4",
"audio/theme.mp3",
"docs/spec.pdf",
])("keeps the default download contract for %s as attachment octet-stream", async (filePath) => {
const root = await makeRoot();
await writeFixture(root, filePath);
const app = makeApp({ getRootDir: vi.fn(() => root) });
const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent(filePath)}/download?workspace=project`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/octet-stream");
expect(res.headers["content-disposition"]).toBe(`attachment; filename="${filePath.split("/").at(-1)}"`);
expect(res.headers["x-content-type-options"]).toBeUndefined();
expect(res.headers["content-security-policy"]).toBeUndefined();
expect(res.body).toBe("fixture-bytes");
});
it("falls back to attachment for inline requests with unknown binary extensions", async () => {
const root = await makeRoot();
await writeFixture(root, "archives/build.zip");
const app = makeApp({ getRootDir: vi.fn(() => root) });
const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent("archives/build.zip")}/download?workspace=project&inline=1`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/octet-stream");
expect(res.headers["content-disposition"]).toBe("attachment; filename=\"build.zip\"");
expect(res.headers["x-content-type-options"]).toBeUndefined();
expect(res.headers["content-security-policy"]).toBeUndefined();
});
it("serves task workspace preview files inline while preserving projectId query propagation", async () => {
const root = await makeRoot();
const taskDir = join(root, ".fusion", "tasks", "FN-123");
await writeFixture(taskDir, "screens/shot.JPG");
const store = {
getRootDir: vi.fn(() => root),
getTask: vi.fn(async () => ({ id: "FN-123", title: "Task" })),
getTaskDir: vi.fn(() => taskDir),
};
const app = makeApp(store);
const res = await REQUEST(app, "GET", `/api/files/${encodeURIComponent("screens/shot.JPG")}/download?workspace=FN-123&projectId=project-a&inline=true`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("image/jpeg");
expect(res.headers["content-disposition"]).toBe("inline; filename=\"shot.JPG\"");
expect(store.getTask).toHaveBeenCalledWith("FN-123");
});
});

View File

@@ -1,5 +1,6 @@
import { access } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { extname } from "node:path";
import type { Request } from "express";
import { ApiError, badRequest } from "../api-error.js";
import {
@@ -33,6 +34,45 @@ function extractFileParams(req: Request): { filePath: string; workspace: string
return { filePath, workspace };
}
/*
FNXC:FileBrowser 2026-06-26-00:00:
The server-side preview MIME map must stay aligned with `packages/dashboard/app/utils/file-preview-kind.ts` so every browser-previewable extension rendered by the file viewer has exactly one safe inline content type, while all unknown extensions keep attachment semantics.
*/
const INLINE_PREVIEW_CONTENT_TYPES = new Map<string, string>([
[".png", "image/png"],
[".jpg", "image/jpeg"],
[".jpeg", "image/jpeg"],
[".gif", "image/gif"],
[".webp", "image/webp"],
[".bmp", "image/bmp"],
[".ico", "image/x-icon"],
[".svg", "image/svg+xml"],
[".svgz", "image/svg+xml"],
[".avif", "image/avif"],
[".mp4", "video/mp4"],
[".webm", "video/webm"],
[".ogg", "video/ogg"],
[".ogv", "video/ogg"],
[".mov", "video/quicktime"],
[".m4v", "video/x-m4v"],
[".mp3", "audio/mpeg"],
[".wav", "audio/wav"],
[".oga", "audio/ogg"],
[".m4a", "audio/mp4"],
[".aac", "audio/aac"],
[".flac", "audio/flac"],
[".opus", "audio/opus"],
[".pdf", "application/pdf"],
]);
function isInlinePreviewRequest(req: Request): boolean {
return req.query.inline === "1" || req.query.inline === "true";
}
function getInlinePreviewContentType(filePath: string): string | null {
return INLINE_PREVIEW_CONTENT_TYPES.get(extname(filePath).toLowerCase()) ?? null;
}
/**
* Registers task-file, workspace-file, and changed-file routes.
*
@@ -232,32 +272,6 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
}
});
router.get("/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
const result = await readWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// MUST be before generic wildcard write route.
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
@@ -369,9 +383,21 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
const inlineContentType = isInlinePreviewRequest(req) ? getInlinePreviewContentType(filePath) : null;
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
/*
FNXC:FileBrowser 2026-06-26-00:00:
File previews opt into inline rendering and receive a renderable MIME type plus `nosniff` and a sandbox CSP so SVG/markup bytes cannot execute on the dashboard origin. The Download button and unrecognized extensions stay on attachment + octet-stream semantics.
*/
if (inlineContentType) {
res.setHeader("Content-Type", inlineContentType);
res.setHeader("Content-Disposition", `inline; filename="${fileName}"`);
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "sandbox");
} else {
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
}
res.setHeader("Content-Length", stats.size);
res.setHeader("Last-Modified", stats.mtime.toUTCString());
@@ -424,6 +450,33 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
}
});
// MUST stay after operation routes so suffix routes like /download are not captured as file paths.
router.get("/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
const result = await readWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// MUST be before generic wildcard write route.
router.post("/files/mkdir", async (req, res) => {
try {