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:
7
.changeset/fn-7031-dock-files-binary-preview.md
Normal file
7
.changeset/fn-7031-dock-files-binary-preview.md
Normal 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.
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user