FN-6972: add browser-native file previews

Files modal now renders common media and PDF files with browser-native previews while preserving editor flows for text and unknown binaries.

- Add shared extension-based preview classification for images, videos, audio, and PDFs.
- Render preview-only files from workspace-safe download URLs and skip binary editor loading/saving actions for those selections.
- Update preview styling, localized labels, documentation, tests, and release notes.

Files changed:
 .changeset/fn-6972-browser-file-previews.md        |   7 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/components/FileBrowser.css  |  39 ++-
 .../dashboard/app/components/FileBrowserModal.tsx  | 107 +++++---
 .../components/__tests__/FileBrowserModal.test.tsx | 302 ++++++++++-----------
 .../app/utils/__tests__/file-preview-kind.test.ts  |  40 +++
 packages/dashboard/app/utils/file-preview-kind.ts  |  70 +++++
 packages/i18n/locales/en/app.json                  |   4 +-
 packages/i18n/locales/es/app.json                  |   4 +-
 packages/i18n/locales/fr/app.json                  |   4 +-
 packages/i18n/locales/ko/app.json                  |   4 +-
 packages/i18n/locales/zh-CN/app.json               |   4 +-
 packages/i18n/locales/zh-TW/app.json               |   4 +-
 13 files changed, 381 insertions(+), 209 deletions(-)

Fusion-Task-Id: FN-6972

Fusion-Task-Lineage: 04b07def-e490-43f7-8b92-39ca704b20b6
This commit is contained in:
gsxdsm
2026-06-25 10:15:47 -07:00
parent 0049fb99d4
commit 4cc9c2f3f1
13 changed files with 392 additions and 220 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Preview images, videos, audio, and PDFs directly in the Files modal.
category: feature
dev: Adds browser-native previews backed by workspace-safe file download URLs.

View File

@@ -670,6 +670,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
- 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

@@ -326,7 +326,8 @@
color: var(--text-muted);
}
.file-browser-binary-indicator {
.file-browser-binary-indicator,
.file-browser-preview-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
@@ -365,7 +366,7 @@
flex-direction: column;
}
.file-browser-image-preview {
.file-browser-preview {
flex: 1;
overflow: auto;
background: var(--bg);
@@ -377,14 +378,32 @@
padding: var(--space-lg);
}
.file-browser-image {
.file-browser-preview-media {
max-width: 100%;
max-height: 100%;
min-width: 0;
border: none;
}
.file-browser-preview-media--image,
.file-browser-preview-media--video {
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.file-browser-preview-media--audio {
width: min(100%, calc(var(--space-xl) * 18));
}
.file-browser-preview-media--pdf {
width: 100%;
height: 100%;
flex: 1;
border-radius: var(--radius-md);
background: var(--surface);
}
.file-browser-footer {
display: flex;
align-items: center;
@@ -537,11 +556,12 @@ Narrow Files windows use the same single-pane list/editor behavior as mobile eve
box-shadow: var(--focus-ring);
}
.file-browser-modal--narrow .file-browser-image-preview {
.file-browser-modal--narrow .file-browser-preview {
padding: var(--space-md);
}
.file-browser-modal--narrow .file-browser-image {
.file-browser-modal--narrow .file-browser-preview-media--image,
.file-browser-modal--narrow .file-browser-preview-media--video {
max-width: 100%;
max-height: calc(100dvh - var(--space-2xl) * 6.25);
}
@@ -731,14 +751,19 @@ Narrow Files windows use the same single-pane list/editor behavior as mobile eve
box-shadow: var(--focus-ring);
}
.file-browser-image-preview {
.file-browser-preview {
padding: var(--space-md);
}
.file-browser-image {
.file-browser-preview-media--image,
.file-browser-preview-media--video {
max-width: 100%;
max-height: calc(100dvh - var(--space-2xl) * 6.25);
}
.file-browser-preview-media--pdf {
min-height: calc(var(--space-xl) * 12);
}
}
/* File Browser Context Menu */

View File

@@ -10,6 +10,7 @@ import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "./FileEditor";
import { FloatingWindow } from "./FloatingWindow";
import { WorkspaceSelector } from "./WorkspaceSelector";
import { getFilePreviewKind, IMAGE_PREVIEW_EXTENSIONS, VIDEO_PREVIEW_EXTENSIONS, AUDIO_PREVIEW_EXTENSIONS, PDF_PREVIEW_EXTENSIONS } from "../utils/file-preview-kind";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
const MOBILE_BREAKPOINT = 768;
@@ -20,33 +21,24 @@ const SIDEBAR_STORAGE_KEY = "fusion:file-browser-sidebar-width";
const FILES_LINE_NUMBERS_STORAGE_KEY = "kb-files-line-numbers";
/**
* Image file extensions that should be rendered as image previews.
*/
const IMAGE_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
]);
/**
* Binary file extensions that should be displayed as read-only.
* Binary file extensions that should be displayed as read-only when the browser cannot preview them natively.
*/
const BINARY_EXTENSIONS = new Set([
...IMAGE_EXTENSIONS,
...IMAGE_PREVIEW_EXTENSIONS,
...VIDEO_PREVIEW_EXTENSIONS,
...AUDIO_PREVIEW_EXTENSIONS,
...PDF_PREVIEW_EXTENSIONS,
".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".avi", ".mkv", ".flv",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".wasm", ".bin",
]);
function isBinaryFile(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}
function isImageFile(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
return Boolean(getFilePreviewKind(filename)) || BINARY_EXTENSIONS.has(ext);
}
function getParentDirectory(path: string): string {
@@ -99,6 +91,9 @@ export function FileBrowserModal({
refresh,
} = useWorkspaceFileBrowser(currentWorkspace, true, projectId);
const selectedPreviewKind = useMemo(() => getFilePreviewKind(selectedFile), [selectedFile]);
const isPreviewOnlyFile = selectedPreviewKind !== null;
const {
content,
setContent,
@@ -109,7 +104,7 @@ export function FileBrowserModal({
save,
hasChanges,
mtime,
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, true, projectId);
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, !isPreviewOnlyFile, projectId);
useEffect(() => {
setCurrentWorkspace(initialWorkspace);
@@ -208,7 +203,7 @@ export function FileBrowserModal({
}
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (hasChanges && !saving) {
if (!isPreviewOnlyFile && hasChanges && !saving) {
void save();
}
}
@@ -216,7 +211,7 @@ export function FileBrowserModal({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, hasChanges, saving, save]);
}, [onClose, hasChanges, saving, save, isPreviewOnlyFile]);
const handleSelectFile = useCallback((path: string) => {
setSelectedFile(path);
@@ -326,11 +321,21 @@ export function FileBrowserModal({
const modalTitle = t("fileBrowser.modalTitle", "Files — {{workspace}}", { workspace: workspaceLabel });
const isNarrowEditorView = Boolean(isMobile && selectedFile && mobileView === "editor" && !isBinaryFile(selectedFile));
// Compute image source URL when an image file is selected
const imageSrc = useMemo(() => {
if (!selectedFile || !isImageFile(selectedFile)) return null;
/*
FNXC:FileBrowser 2026-06-25-00:00:
Known image, video, audio, and PDF files must use browser-native previews from the workspace-safe download route without fetching binary content into CodeMirror. Editable text and unknown binary files keep the existing editor/read-only paths so save/discard, selection comments, and binary indicators remain scoped to editor-backed files.
*/
const previewUrl = useMemo(() => {
if (!selectedFile || !selectedPreviewKind) return null;
return downloadFileUrl(currentWorkspace, selectedFile, projectId);
}, [selectedFile, currentWorkspace, projectId]);
}, [currentWorkspace, projectId, selectedFile, selectedPreviewKind]);
const selectedPreviewLabel = selectedFile
? t("fileBrowser.previewOnly", "Preview only")
: null;
const selectedPreviewTitle = selectedFile
? t("fileBrowser.previewTitle", "Preview for {{file}}", { file: selectedFile })
: "";
const formatFileSize = (value: string): string => {
const bytes = new Blob([value]).size;
@@ -440,12 +445,17 @@ export function FileBrowserModal({
</button>
)}
{selectedFile}
{isBinaryFile(selectedFile) && (
{selectedPreviewKind && selectedPreviewLabel ? (
<span className="file-browser-preview-indicator">
<FileType size={12} />
{selectedPreviewLabel}
</span>
) : isBinaryFile(selectedFile) ? (
<span className="file-browser-binary-indicator">
<FileType size={12} />
{t("fileBrowser.binaryReadOnly", "Binary file — read only")}
</span>
)}
) : null}
{mtime && (
<span className="file-browser-mtime">
{t("fileBrowser.modified", "Modified: {{date}}", { date: new Date(mtime).toLocaleString() })}
@@ -456,7 +466,7 @@ export function FileBrowserModal({
)}
</div>
<div className="file-browser-actions">
{!imageSrc && hasChanges && (
{!previewUrl && hasChanges && (
<>
<button
className="btn btn-sm"
@@ -479,17 +489,42 @@ export function FileBrowserModal({
</div>
</div>
{editorError && !imageSrc && (
{editorError && !previewUrl && (
<div className="file-browser-error-banner">{editorError}</div>
)}
{imageSrc ? (
<div className="file-browser-image-preview">
<img
src={imageSrc}
alt={selectedFile ?? ""}
className="file-browser-image"
/>
{previewUrl && selectedPreviewKind ? (
<div className={`file-browser-preview file-browser-preview--${selectedPreviewKind}`}>
{selectedPreviewKind === "image" && (
<img
src={previewUrl}
alt={selectedFile ?? ""}
className="file-browser-preview-media file-browser-preview-media--image"
/>
)}
{selectedPreviewKind === "video" && (
<video
src={previewUrl}
controls
aria-label={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--video"
/>
)}
{selectedPreviewKind === "audio" && (
<audio
src={previewUrl}
controls
aria-label={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--audio"
/>
)}
{selectedPreviewKind === "pdf" && (
<iframe
src={previewUrl}
title={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--pdf"
/>
)}
</div>
) : (
<div className="file-editor-wrapper">
@@ -509,7 +544,7 @@ export function FileBrowserModal({
</div>
)}
{!imageSrc && (
{!previewUrl && (
<div className="file-browser-footer">
<span>{formatFileSize(content)}</span>
{hasChanges && <span className="file-browser-unsaved">{t("fileBrowser.unsavedChanges", "Unsaved changes")}</span>}

View File

@@ -1,3 +1,4 @@
import type { ComponentProps } from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -759,222 +760,203 @@ describe("FileBrowserModal", () => {
});
});
describe("image file preview", () => {
it("renders image preview for .png files instead of editor", async () => {
describe("browser-native file previews", () => {
const renderWithEntries = (entries: typeof defaultBrowserState.entries, props: Partial<ComponentProps<typeof FileBrowserModal>> = {}) => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "screenshot.png", type: "file" as const, size: 102400, mtime: "2024-01-01" },
],
entries,
});
render(
return render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
{...props}
/>,
);
};
// Select the image file
const selectFile = async (name: string) => {
await act(async () => {
fireEvent.click(screen.getByText("screenshot.png"));
fireEvent.click(screen.getByText(name));
});
};
// Should render an image preview
const imagePreview = screen.getByRole("img", { name: "screenshot.png" });
expect(imagePreview).toBeInTheDocument();
expect(imagePreview).toHaveAttribute("src", expect.stringContaining("screenshot.png"));
// Should NOT render the text editor
expect(screen.queryByLabelText(/Editor for screenshot.png/)).not.toBeInTheDocument();
});
it("renders image preview for .jpg files instead of editor", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "photo.jpg", type: "file" as const, size: 204800, mtime: "2024-01-01" },
],
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
await act(async () => {
fireEvent.click(screen.getByText("photo.jpg"));
});
const imagePreview = screen.getByRole("img", { name: "photo.jpg" });
expect(imagePreview).toBeInTheDocument();
});
it("renders image preview for .gif files instead of editor", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "animation.gif", type: "file" as const, size: 51200, mtime: "2024-01-01" },
],
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
await act(async () => {
fireEvent.click(screen.getByText("animation.gif"));
});
const imagePreview = screen.getByRole("img", { name: "animation.gif" });
expect(imagePreview).toBeInTheDocument();
});
it("renders image preview for .webp files instead of editor", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "image.webp", type: "file" as const, size: 76800, mtime: "2024-01-01" },
],
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
await act(async () => {
fireEvent.click(screen.getByText("image.webp"));
});
const imagePreview = screen.getByRole("img", { name: "image.webp" });
expect(imagePreview).toBeInTheDocument();
});
it("hides save/discard actions for image files", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "test.png", type: "file" as const, size: 1024, mtime: "2024-01-01" },
],
});
// Mock editor state with changes
it.each([
{
name: "screenshot.png",
role: "img" as const,
selector: "img.file-browser-preview-media--image",
attribute: "src",
},
{
name: "clip.mp4",
role: null,
selector: "video.file-browser-preview-media--video",
attribute: "src",
},
{
name: "voice.mp3",
role: null,
selector: "audio.file-browser-preview-media--audio",
attribute: "src",
},
{
name: "manual.pdf",
role: null,
selector: "iframe.file-browser-preview-media--pdf",
attribute: "src",
},
])("renders $name with the native project preview element", async ({ name, role, selector, attribute }) => {
mockUseWorkspaceFileEditor.mockReturnValue({
...defaultEditorState,
hasChanges: true,
});
renderWithEntries([
{ name, type: "file" as const, size: 102400, mtime: "2024-01-01" },
]);
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
await selectFile(name);
await act(async () => {
fireEvent.click(screen.getByText("test.png"));
});
const preview = role === "img"
? screen.getByRole(role, { name })
: document.querySelector(selector);
expect(preview).toBeInTheDocument();
expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name)));
expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project"));
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`);
}
if (selector.startsWith("iframe")) {
expect(preview).toHaveAttribute("title", `Preview for ${name}`);
}
// Should NOT show Discard or Save buttons for images
expect(screen.getByText("Preview only")).toBeInTheDocument();
expect(screen.queryByText(/Binary file — read only/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(new RegExp(`Editor for ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`))).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /toggle editor options/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Discard/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument();
expect(document.querySelector(".file-editor-wrapper")).not.toBeInTheDocument();
expect(document.querySelector(".file-browser-footer")).not.toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", name, false, undefined);
});
it("still shows save/discard actions for text files with changes", async () => {
// Mock editor state with changes
it("renders task-workspace preview URLs with project scoping", async () => {
renderWithEntries([
{ name: "movie.mov", type: "file" as const, size: 204800, mtime: "2024-01-01" },
], {
initialWorkspace: "FN-001",
projectId: "proj-1",
});
await selectFile("movie.mov");
const video = document.querySelector("video.file-browser-preview-media--video");
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("movie.mov"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1");
});
it("previews uppercase and nested PDF paths without loading editor content", async () => {
render(
<FileBrowserModal
initialWorkspace="project"
initialFile="docs/MANUAL.PDF"
isOpen={true}
onClose={mockOnClose}
/>,
);
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("title", "Preview for docs/MANUAL.PDF");
expect(mockSetPath).toHaveBeenCalledWith("docs");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
});
it("keeps text files editable with save and discard controls", async () => {
mockUseWorkspaceFileEditor.mockReturnValue({
...defaultEditorState,
hasChanges: true,
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
renderWithEntries([
{ name: "file1.ts", type: "file" as const, size: 1024, mtime: "2024-01-01" },
]);
// Select a text file
await act(async () => {
fireEvent.click(screen.getByText("file1.ts"));
});
await selectFile("file1.ts");
// Should show Discard and Save buttons
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Discard/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
});
it("renders file editor for non-image binary files like .pdf", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "document.pdf", type: "file" as const, size: 1024000, mtime: "2024-01-01" },
],
});
it("keeps unknown binary files in the read-only editor fallback", async () => {
renderWithEntries([
{ name: "archive.zip", type: "file" as const, size: 1024, mtime: "2024-01-01" },
]);
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
await selectFile("archive.zip");
await act(async () => {
fireEvent.click(screen.getByText("document.pdf"));
});
// Should show binary indicator
expect(screen.getByText(/Binary file — read only/)).toBeInTheDocument();
// Should NOT render an image preview
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByLabelText("Editor for archive.zip")).toBeInTheDocument();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "archive.zip", true, undefined);
});
it("image preview uses workspace-safe URL pattern", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [
{ name: "test.png", type: "file" as const, size: 1024, mtime: "2024-01-01" },
],
it("keeps the no-selected-file placeholder until a previewable file is selected", async () => {
renderWithEntries([
{ name: "voice.mp3", type: "file" as const, size: 1024, mtime: "2024-01-01" },
]);
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
await selectFile("voice.mp3");
expect(screen.queryByText("Select a file to edit")).not.toBeInTheDocument();
expect(document.querySelector("audio.file-browser-preview-media--audio")).toBeInTheDocument();
});
it("updates preview-only state across repeated selections", async () => {
renderWithEntries([
{ name: "manual.pdf", type: "file" as const, size: 1024, mtime: "2024-01-01" },
{ name: "clip.mp4", type: "file" as const, size: 1024, mtime: "2024-01-01" },
]);
await selectFile("manual.pdf");
expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument();
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();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined);
});
it("renders preview-only files in the mobile editor pane with back navigation", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 375,
});
render(
<FileBrowserModal
initialWorkspace="FN-001"
isOpen={true}
onClose={mockOnClose}
/>,
);
renderWithEntries([
{ name: "voice.mp3", type: "file" as const, size: 1024, mtime: "2024-01-01" },
]);
await act(async () => {
fireEvent.click(screen.getByText("test.png"));
});
fireEvent(window, new Event("resize"));
await selectFile("voice.mp3");
const imagePreview = screen.getByRole("img", { name: "test.png" });
// URL should include workspace parameter
expect(imagePreview).toHaveAttribute(
"src",
expect.stringContaining("workspace=FN-001")
);
expect(imagePreview).toHaveAttribute(
"src",
expect.stringContaining("test.png")
);
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
expect(document.querySelector("audio.file-browser-preview-media--audio")).toBeInTheDocument();
expect(document.querySelector(".file-browser-content.mobile.active")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getFilePreviewKind } from "../file-preview-kind";
describe("getFilePreviewKind", () => {
it.each([
["screenshot.png", "image"],
["photo.JPG", "image"],
["icons/logo.svg", "image"],
["nested/assets/brand.AVIF", "image"],
["clip.mp4", "video"],
["recordings/movie.MOV", "video"],
["nested/video.ogv", "video"],
["voice.mp3", "audio"],
["audio/VOICE.WAV", "audio"],
["nested/audio/song.oga", "audio"],
["manual.pdf", "pdf"],
["docs/MANUAL.PDF", "pdf"],
] as const)("returns %s as %s", (filePath, expectedKind) => {
expect(getFilePreviewKind(filePath)).toBe(expectedKind);
});
it.each([
"archive.zip",
"binary.bin",
"README.md",
"src/App.tsx",
"Makefile",
".env",
"",
" ",
undefined,
null,
] as const)("returns null for non-previewable path %s", (filePath) => {
expect(getFilePreviewKind(filePath)).toBeNull();
});
it("uses the shared Ogg extension as video for deterministic media rendering", () => {
expect(getFilePreviewKind("captures/demo.ogg")).toBe("video");
});
});

View File

@@ -0,0 +1,70 @@
export type FilePreviewKind = "image" | "video" | "audio" | "pdf";
export const IMAGE_PREVIEW_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".ico",
".svg",
".svgz",
".avif",
]);
export const VIDEO_PREVIEW_EXTENSIONS = new Set([
".mp4",
".webm",
".ogg",
".ogv",
".mov",
".m4v",
]);
export const AUDIO_PREVIEW_EXTENSIONS = new Set([
".mp3",
".wav",
".oga",
".m4a",
".aac",
".flac",
".opus",
]);
export const PDF_PREVIEW_EXTENSIONS = new Set([".pdf"]);
/**
* FNXC:FileBrowser 2026-06-25-00:00:
* Files browsing needs extension-only preview classification to be shared by editor loading and rendering. Keep `.ogg` classified as video because browsers can render Ogg video natively and audio-only Ogg files can still be opened by the same media element path without fetching binary text.
*/
export function getFilePreviewKind(filePath?: string | null): FilePreviewKind | null {
if (!filePath) {
return null;
}
const normalizedPath = filePath.trim().toLowerCase();
const lastSlash = Math.max(normalizedPath.lastIndexOf("/"), normalizedPath.lastIndexOf("\\"));
const filename = normalizedPath.slice(lastSlash + 1);
const dotIndex = filename.lastIndexOf(".");
if (dotIndex <= 0) {
return null;
}
const extension = filename.slice(dotIndex);
if (IMAGE_PREVIEW_EXTENSIONS.has(extension)) {
return "image";
}
if (VIDEO_PREVIEW_EXTENSIONS.has(extension)) {
return "video";
}
if (AUDIO_PREVIEW_EXTENSIONS.has(extension)) {
return "audio";
}
if (PDF_PREVIEW_EXTENSIONS.has(extension)) {
return "pdf";
}
return null;
}

View File

@@ -2394,7 +2394,9 @@
"typeFolder": "Folder",
"unsavedChanges": "Unsaved changes",
"upOneLevel": "Up one level",
"workspaceProject": "Project"
"workspaceProject": "Project",
"previewOnly": "Preview only",
"previewTitle": "Preview for {{file}}"
},
"fileEditor": {
"edit": "Edit",

View File

@@ -2384,7 +2384,9 @@
"typeFolder": "Carpeta",
"unsavedChanges": "Cambios no guardados",
"upOneLevel": "Subir un nivel",
"workspaceProject": "Proyecto"
"workspaceProject": "Proyecto",
"previewOnly": "Solo vista previa",
"previewTitle": "Vista previa de {{file}}"
},
"fileEditor": {
"edit": "Editar",

View File

@@ -2384,7 +2384,9 @@
"typeFolder": "Dossier",
"unsavedChanges": "Modifications non enregistrées",
"upOneLevel": "Remonter d'un niveau",
"workspaceProject": "Projet"
"workspaceProject": "Projet",
"previewOnly": "Aperçu uniquement",
"previewTitle": "Aperçu de {{file}}"
},
"fileEditor": {
"edit": "Modifier",

View File

@@ -2384,7 +2384,9 @@
"typeFolder": "폴더",
"unsavedChanges": "저장되지 않은 변경 사항",
"upOneLevel": "상위 폴더로",
"workspaceProject": "프로젝트"
"workspaceProject": "프로젝트",
"previewOnly": "미리보기 전용",
"previewTitle": "{{file}} 미리보기"
},
"fileEditor": {
"edit": "편집",

View File

@@ -2384,7 +2384,9 @@
"typeFolder": "文件夹",
"unsavedChanges": "未保存的更改",
"upOneLevel": "上一级",
"workspaceProject": "项目"
"workspaceProject": "项目",
"previewOnly": "仅预览",
"previewTitle": "{{file}} 的预览"
},
"fileEditor": {
"edit": "编辑",

View File

@@ -2384,7 +2384,9 @@
"typeFolder": "資料夾",
"unsavedChanges": "未儲存的變更",
"upOneLevel": "上一層",
"workspaceProject": "專案"
"workspaceProject": "專案",
"previewOnly": "僅預覽",
"previewTitle": "{{file}} 的預覽"
},
"fileEditor": {
"edit": "編輯",