FN-7031: preview binary media files in dock Files viewer

Render browser-native previews for right-dock file selections while preserving text editing behavior.

- Add image, video, audio, and PDF preview rendering through the workspace download URL.
- Keep previewable and known binary files out of the text editor, with a read-only fallback for unsupported binaries.
- Cover compact and two-pane dock preview behavior with tests, docs, and a patch changeset.

Files changed:
 .changeset/fn-7031-dock-files-binary-preview.md    |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/DockFilesView.css     |  45 ++++++
 .../dashboard/app/components/DockFilesView.tsx     |  85 ++++++++++-
 .../components/__tests__/DockFilesView.test.tsx    | 170 ++++++++++++++++++++-
 5 files changed, 303 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7031

Fusion-Task-Lineage: a52bdd57-bb3e-494e-85a8-81326a1548e2
This commit is contained in:
gsxdsm
2026-06-26 00:43:55 -07:00
parent 2ce208e36d
commit f3f20accbb
5 changed files with 303 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preview image, video, audio, and PDF files natively in the right-dock Files viewer.
category: fix
dev: Reuses the shared file-preview classification and download route in DockFilesView.

View File

@@ -52,7 +52,7 @@ The **Right Dock Panel** experiment is enabled by default. To disable it, open *
When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. Use the in-dock collapse control to switch between the full tool panel and the compact far-right rail; the selected tool, expanded/collapsed state, width, and expanded modal size persist across reloads. When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. Use the in-dock collapse control to switch between the full tool panel and the compact far-right rail; the selected tool, expanded/collapsed state, width, and expanded modal size persist across reloads.
The dock toolbar has built-in inline tool panels for **Activity**, **Activity Log**, **Git Manager**, **Files**, and project tool launchers such as **Import from GitHub** / **Import Tasks** workflow entry points and **Automation** actions when available. **Activity**, **Activity Log**, **Git Manager**, and **Files** render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar. The dock toolbar has built-in inline tool panels for **Activity**, **Activity Log**, **Git Manager**, **Files**, and project tool launchers such as **Import from GitHub** / **Import Tasks** workflow entry points and **Automation** actions when available. **Activity**, **Activity Log**, **Git Manager**, and **Files** render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. The right-dock **Files** viewer and its expanded pop-out match the Files modal for browser-previewable file types: image, video/movie, audio, and PDF selections render as native browser previews, while editable text files keep the editor and save flow. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar.
Use the desktop/tablet right dock this way: Use the desktop/tablet right dock this way:

View File

@@ -109,6 +109,39 @@ Hidden until a file is selected; when selected it overlays the tree as the singl
min-height: 0; min-height: 0;
} }
/*
FNXC:RightDockFiles 2026-06-25-00:00:
The dock Files viewer shares FileBrowserModal's native preview classes but needs dock-scoped flex sizing so image/video/audio/PDF previews fit both the compact single-panel stack and the two-pane pop-out without overflowing or showing the CodeMirror shell.
*/
.dock-files-preview {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
overflow: auto;
}
.dock-files-preview__media {
min-width: 0;
}
.dock-files-preview .file-browser-preview-media--image,
.dock-files-preview .file-browser-preview-media--video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.dock-files-preview .file-browser-preview-media--audio {
width: min(100%, calc(var(--space-xl) * 18));
}
.dock-files-preview .file-browser-preview-media--pdf {
width: 100%;
min-height: 0;
flex: 1 1 auto;
}
.dock-files-viewer__status { .dock-files-viewer__status {
padding: var(--space-md); padding: var(--space-md);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
@@ -203,3 +236,15 @@ DETERMINISTIC two-pane layout for the RightDockExpandModal pop-out. Driven by th
.dock-files-view--two-pane .dock-files-view__viewer .dock-files-viewer__back { .dock-files-view--two-pane .dock-files-view__viewer .dock-files-viewer__back {
display: none; display: none;
} }
@media (max-width: 768px) {
.dock-files-preview {
padding: var(--space-md);
}
.dock-files-preview .file-browser-preview-media--image,
.dock-files-preview .file-browser-preview-media--video,
.dock-files-preview .file-browser-preview-media--pdf {
width: 100%;
}
}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, Maximize2, Save } from "lucide-react"; import { ArrowLeft, Maximize2, Save } from "lucide-react";
import type { PluginDashboardViewContext } from "../plugins/types"; import type { PluginDashboardViewContext } from "../plugins/types";
import { downloadFileUrl } from "../api";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor"; import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
import { getFilePreviewKind, IMAGE_PREVIEW_EXTENSIONS, VIDEO_PREVIEW_EXTENSIONS, AUDIO_PREVIEW_EXTENSIONS, PDF_PREVIEW_EXTENSIONS } from "../utils/file-preview-kind";
import { FileBrowser } from "./FileBrowser"; import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
import "./DockFilesView.css"; import "./DockFilesView.css";
@@ -28,6 +30,30 @@ Share the current-file path through scoped localStorage (`kb-dashboard-dock-file
*/ */
export const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current"; export const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current";
const BINARY_EXTENSIONS = new Set([
...IMAGE_PREVIEW_EXTENSIONS,
...VIDEO_PREVIEW_EXTENSIONS,
...AUDIO_PREVIEW_EXTENSIONS,
...PDF_PREVIEW_EXTENSIONS,
".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".avi", ".mkv", ".flv",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".wasm", ".bin",
]);
function isBinaryFile(filename?: string | null): boolean {
if (!filename) {
return false;
}
const dotIndex = filename.lastIndexOf(".");
if (dotIndex < 0) {
return false;
}
return BINARY_EXTENSIONS.has(filename.slice(dotIndex).toLowerCase());
}
/* /*
FNXC:RightDockFiles 2026-06-22-00:00: FNXC:RightDockFiles 2026-06-22-00:00:
The right-dock Files tool opens a clicked file INLINE inside the dock as a read-only viewer instead of immediately launching the resizable/movable FileBrowserModal. The right-dock Files tool opens a clicked file INLINE inside the dock as a read-only viewer instead of immediately launching the resizable/movable FileBrowserModal.
@@ -78,9 +104,22 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
return () => window.removeEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage);
}, [projectId]); }, [projectId]);
const selectedPreviewKind = useMemo(() => getFilePreviewKind(selectedFile), [selectedFile]);
const isPreviewOnlyFile = selectedPreviewKind !== null;
const isReadOnlyBinaryFile = Boolean(selectedFile && !isPreviewOnlyFile && isBinaryFile(selectedFile));
const previewUrl = useMemo(() => {
if (!selectedFile || !selectedPreviewKind) {
return null;
}
return downloadFileUrl("project", selectedFile, projectId);
}, [projectId, selectedFile, selectedPreviewKind]);
/* /*
FNXC:RightDockFiles 2026-06-22-16:28: FNXC:RightDockFiles 2026-06-22-16:28:
The right-sidebar file viewer must be the same editor surface as the modal/mobile file browser: real workspace editor state, visible toolbar options, Preview/Edit for markdown, Line #, and Wrap. Use the shared editor hook instead of the old read-only content fetch so edits can be saved and the toolbar is not a reduced sidebar-only variant. The right-sidebar file viewer must be the same editor surface as the modal/mobile file browser: real workspace editor state, visible toolbar options, Preview/Edit for markdown, Line #, and Wrap. Use the shared editor hook instead of the old read-only content fetch so edits can be saved and the toolbar is not a reduced sidebar-only variant.
FNXC:RightDockFiles 2026-06-25-00:00:
Known image/video/audio/PDF selections render through browser-native previews loaded from the workspace-safe download route. Keep those preview-only files out of CodeMirror so binary bytes are never fetched as editor text; editable text keeps the existing FileEditor + Save path, while known non-preview binary extensions stay read-only without loading bytes as editor content.
*/ */
const { const {
content, content,
@@ -90,7 +129,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
error: contentError, error: contentError,
save, save,
hasChanges, hasChanges,
} = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile), projectId); } = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile) && !isPreviewOnlyFile && !isReadOnlyBinaryFile, projectId);
const handleBack = useCallback(() => selectFile(null), [selectFile]); const handleBack = useCallback(() => selectFile(null), [selectFile]);
const handlePopOut = useCallback(() => { const handlePopOut = useCallback(() => {
@@ -99,6 +138,9 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
const handleToggleLineNumbers = useCallback(() => setShowLineNumbers((current) => !current), []); const handleToggleLineNumbers = useCallback(() => setShowLineNumbers((current) => !current), []);
const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : ""; const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : "";
const selectedPreviewTitle = selectedFile
? t("fileBrowser.previewTitle", "Preview for {{file}}", { file: selectedFile })
: "";
// FNXC:Files 2026-06-22-00:00: // FNXC:Files 2026-06-22-00:00:
// `data-selected` on the root lets the container query distinguish "no file selected" (narrow: viewer pane hidden so only the tree shows) from "file selected" (narrow: viewer pane covers the stack). When wide both panes are always visible regardless of this flag. // `data-selected` on the root lets the container query distinguish "no file selected" (narrow: viewer pane hidden so only the tree shows) from "file selected" (narrow: viewer pane covers the stack). When wide both panes are always visible regardless of this flag.
@@ -155,7 +197,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
> >
<Maximize2 size={14} /> <Maximize2 size={14} />
</button> </button>
{selectedFile ? ( {selectedFile && !isPreviewOnlyFile && !isReadOnlyBinaryFile ? (
<button <button
type="button" type="button"
className="btn btn-sm btn-primary dock-files-viewer__save" className="btn btn-sm btn-primary dock-files-viewer__save"
@@ -173,6 +215,43 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
<div className="dock-files-viewer__status dock-files-viewer__empty" data-testid="right-dock-files-empty"> <div className="dock-files-viewer__status dock-files-viewer__empty" data-testid="right-dock-files-empty">
{t("fileViewer.selectAFile", "Select a file")} {t("fileViewer.selectAFile", "Select a file")}
</div> </div>
) : previewUrl && selectedPreviewKind ? (
<div className={`file-browser-preview file-browser-preview--${selectedPreviewKind} dock-files-preview`}>
{selectedPreviewKind === "image" ? (
<img
src={previewUrl}
alt={selectedFile}
className="file-browser-preview-media file-browser-preview-media--image dock-files-preview__media"
/>
) : null}
{selectedPreviewKind === "video" ? (
<video
src={previewUrl}
controls
aria-label={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--video dock-files-preview__media"
/>
) : null}
{selectedPreviewKind === "audio" ? (
<audio
src={previewUrl}
controls
aria-label={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--audio dock-files-preview__media"
/>
) : null}
{selectedPreviewKind === "pdf" ? (
<iframe
src={previewUrl}
title={selectedPreviewTitle}
className="file-browser-preview-media file-browser-preview-media--pdf dock-files-preview__media"
/>
) : null}
</div>
) : isReadOnlyBinaryFile ? (
<div className="dock-files-viewer__status" data-testid="right-dock-files-binary-read-only">
{t("fileBrowser.binaryReadOnly", "Binary file — read only")}
</div>
) : contentLoading ? ( ) : contentLoading ? (
<div className="dock-files-viewer__status">{t("common.loading", "Loading...")}</div> <div className="dock-files-viewer__status">{t("common.loading", "Loading...")}</div>
) : contentError ? ( ) : contentError ? (

View File

@@ -12,11 +12,24 @@ Proves the current-file path is shared between the dock instance and the popped-
*/ */
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), useTranslation: () => ({
t: (_key: string, fallback?: string, options?: Record<string, string>) => {
const value = fallback ?? _key;
return options?.file ? value.replace("{{file}}", options.file) : value;
},
}),
})); }));
const entries: FileNode[] = [ let entries: FileNode[] = [];
const defaultEntries: FileNode[] = [
{ name: "readme.md", type: "file", size: 10, mtime: "2026-01-15T10:30:00Z" }, { name: "readme.md", type: "file", size: 10, mtime: "2026-01-15T10:30:00Z" },
{ name: "changed.txt", type: "file", size: 12, mtime: "2026-01-15T10:30:00Z" },
{ name: "assets/Logo.PNG", type: "file", size: 1024, mtime: "2026-01-15T10:30:00Z" },
{ name: "media/demo.mp4", type: "file", size: 2048, mtime: "2026-01-15T10:30:00Z" },
{ name: "sounds/theme.MP3", type: "file", size: 2048, mtime: "2026-01-15T10:30:00Z" },
{ name: "docs/manual.PDF", type: "file", size: 4096, mtime: "2026-01-15T10:30:00Z" },
{ name: "build/output.bin", type: "file", size: 8192, mtime: "2026-01-15T10:30:00Z" },
]; ];
const dockFilesCss = readFileSync(resolve(__dirname, "../DockFilesView.css"), "utf8"); const dockFilesCss = readFileSync(resolve(__dirname, "../DockFilesView.css"), "utf8");
@@ -34,9 +47,42 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" })); const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" })); const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
const mockDownloadFileUrl = vi.fn((workspace: string, filePath: string, projectId?: string) => {
const params = new URLSearchParams({ workspace });
if (projectId) params.set("projectId", projectId);
return `/api/files/${encodeURIComponent(filePath)}?${params.toString()}`;
});
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])), fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])), saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])),
downloadFileUrl: (...args: unknown[]) => mockDownloadFileUrl(...(args as [string, string, string | undefined])),
}));
const capturedEditorHookCalls: Array<{
workspace: string;
filePath: string | null;
enabled: boolean;
projectId?: string;
}> = [];
const mockSetContent = vi.fn();
const mockSave = vi.fn(() => Promise.resolve());
vi.mock("../../hooks/useWorkspaceFileEditor", () => ({
useWorkspaceFileEditor: (workspace: string, filePath: string | null, enabled: boolean, projectId?: string) => {
capturedEditorHookCalls.push({ workspace, filePath, enabled, projectId });
const hasChanges = filePath === "changed.txt";
return {
content: filePath ? `content for ${filePath}` : "",
setContent: mockSetContent,
originalContent: hasChanges ? "original" : filePath ? `content for ${filePath}` : "",
loading: false,
saving: false,
error: null,
save: mockSave,
hasChanges,
mtime: null,
};
},
})); }));
const capturedFileEditorProps: Array<{ const capturedFileEditorProps: Array<{
@@ -82,9 +128,14 @@ const KEY = scopedKey("kb-dashboard-dock-files-current", PROJECT_ID);
describe("DockFilesView shared current-file state", () => { describe("DockFilesView shared current-file state", () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
entries = [...defaultEntries];
mockFetchContent.mockClear(); mockFetchContent.mockClear();
mockSaveContent.mockClear(); mockSaveContent.mockClear();
mockDownloadFileUrl.mockClear();
mockSetContent.mockClear();
mockSave.mockClear();
capturedFileEditorProps.length = 0; capturedFileEditorProps.length = 0;
capturedEditorHookCalls.length = 0;
}); });
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -165,4 +216,119 @@ describe("DockFilesView shared current-file state", () => {
expect(latest?.onToggleLineNumbers).toEqual(expect.any(Function)); expect(latest?.onToggleLineNumbers).toEqual(expect.any(Function));
expect(screen.getByTestId("right-dock-files-save")).toBeDisabled(); expect(screen.getByTestId("right-dock-files-save")).toBeDisabled();
}); });
it.each([
{
layout: "auto" as const,
file: "assets/Logo.PNG",
selector: "img.file-browser-preview-media--image",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "media/demo.mp4",
selector: "video.file-browser-preview-media--video",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "sounds/theme.MP3",
selector: "audio.file-browser-preview-media--audio",
attr: "src",
expectedEnabled: false,
},
{
layout: "auto" as const,
file: "docs/manual.PDF",
selector: "iframe.file-browser-preview-media--pdf",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "assets/Logo.PNG",
selector: "img.file-browser-preview-media--image",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "media/demo.mp4",
selector: "video.file-browser-preview-media--video",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "sounds/theme.MP3",
selector: "audio.file-browser-preview-media--audio",
attr: "src",
expectedEnabled: false,
},
{
layout: "two-pane" as const,
file: "docs/manual.PDF",
selector: "iframe.file-browser-preview-media--pdf",
attr: "src",
expectedEnabled: false,
},
])("renders $file as a native preview in $layout layout without loading the editor", async ({ layout, file, selector, attr }) => {
/*
FNXC:RightDockFiles 2026-06-25-00:00:
Symptom verification: browser-previewable files in both compact and pop-out dock layouts must use native preview elements backed by downloadFileUrl, not the FileEditor/CodeMirror binary-text path.
*/
render(<DockFilesView projectId={PROJECT_ID} layout={layout} />);
fireEvent.click(screen.getByText(file));
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}`);
if (selector.startsWith("video") || selector.startsWith("audio")) {
expect(preview).toHaveAttribute("controls");
expect(preview).toHaveAttribute("aria-label", `Preview for ${file}`);
}
if (selector.startsWith("iframe")) {
expect(preview).toHaveAttribute("title", `Preview for ${file}`);
}
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);
});
it("keeps text files editable with Save when changes exist", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("changed.txt"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "changed.txt"));
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 });
});
it("keeps known non-preview binary files read-only without rendering garbage editor content", () => {
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
fireEvent.click(screen.getByText("build/output.bin"));
expect(screen.getByTestId("right-dock-files-binary-read-only")).toHaveTextContent("Binary file — read only");
expect(screen.queryByTestId("mock-file-editor")).toBeNull();
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 });
});
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());
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
expect(document.querySelector("img.file-browser-preview-media--image")).not.toBeInTheDocument();
expect(screen.getByTestId("right-dock-files-save")).toBeDisabled();
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "readme.md", enabled: true, projectId: PROJECT_ID });
});
}); });