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:
7
.changeset/fn-6972-browser-file-previews.md
Normal file
7
.changeset/fn-6972-browser-file-previews.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
70
packages/dashboard/app/utils/file-preview-kind.ts
Normal file
70
packages/dashboard/app/utils/file-preview-kind.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2384,7 +2384,9 @@
|
||||
"typeFolder": "폴더",
|
||||
"unsavedChanges": "저장되지 않은 변경 사항",
|
||||
"upOneLevel": "상위 폴더로",
|
||||
"workspaceProject": "프로젝트"
|
||||
"workspaceProject": "프로젝트",
|
||||
"previewOnly": "미리보기 전용",
|
||||
"previewTitle": "{{file}} 미리보기"
|
||||
},
|
||||
"fileEditor": {
|
||||
"edit": "편집",
|
||||
|
||||
@@ -2384,7 +2384,9 @@
|
||||
"typeFolder": "文件夹",
|
||||
"unsavedChanges": "未保存的更改",
|
||||
"upOneLevel": "上一级",
|
||||
"workspaceProject": "项目"
|
||||
"workspaceProject": "项目",
|
||||
"previewOnly": "仅预览",
|
||||
"previewTitle": "{{file}} 的预览"
|
||||
},
|
||||
"fileEditor": {
|
||||
"edit": "编辑",
|
||||
|
||||
@@ -2384,7 +2384,9 @@
|
||||
"typeFolder": "資料夾",
|
||||
"unsavedChanges": "未儲存的變更",
|
||||
"upOneLevel": "上一層",
|
||||
"workspaceProject": "專案"
|
||||
"workspaceProject": "專案",
|
||||
"previewOnly": "僅預覽",
|
||||
"previewTitle": "{{file}} 的預覽"
|
||||
},
|
||||
"fileEditor": {
|
||||
"edit": "編輯",
|
||||
|
||||
Reference in New Issue
Block a user