feat(FN-1495): merge fusion/fn-1495
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import * as workspaceBrowserHook from "../hooks/useWorkspaceFileBrowser";
|
||||
@@ -311,6 +311,225 @@ describe("FileBrowserModal", () => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("image file preview", () => {
|
||||
it("renders image preview for .png files instead of editor", async () => {
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||
...defaultBrowserState,
|
||||
entries: [
|
||||
{ name: "screenshot.png", type: "file" as const, size: 102400, mtime: "2024-01-01" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select the image file
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("screenshot.png"));
|
||||
});
|
||||
|
||||
// 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
|
||||
mockUseWorkspaceFileEditor.mockReturnValue({
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("test.png"));
|
||||
});
|
||||
|
||||
// Should NOT show Discard or Save buttons for images
|
||||
expect(screen.queryByRole("button", { name: /Discard/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still shows save/discard actions for text files with changes", async () => {
|
||||
// Mock editor state with changes
|
||||
mockUseWorkspaceFileEditor.mockReturnValue({
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select a text file
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
});
|
||||
|
||||
// Should show Discard and Save buttons
|
||||
expect(screen.getByRole("button", { name: /Discard/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="FN-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("test.png"));
|
||||
});
|
||||
|
||||
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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal height constraint regression", () => {
|
||||
it("max-height uses calc() to stay within viewport padding", async () => {
|
||||
const fs = await import("fs");
|
||||
|
||||
@@ -3,17 +3,25 @@ import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||
import { downloadFileUrl } from "../api";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { WorkspaceSelector } from "./WorkspaceSelector";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
|
||||
...IMAGE_EXTENSIONS,
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
@@ -27,6 +35,11 @@ function isBinaryFile(filename: string): boolean {
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isImageFile(filename: string): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
interface FileBrowserModalProps {
|
||||
isOpen?: boolean;
|
||||
initialWorkspace?: string;
|
||||
@@ -139,6 +152,12 @@ export function FileBrowserModal({
|
||||
|
||||
const modalTitle = `Files — ${workspaceLabel}`;
|
||||
|
||||
// Compute image source URL when an image file is selected
|
||||
const imageSrc = useMemo(() => {
|
||||
if (!selectedFile || !isImageFile(selectedFile)) return null;
|
||||
return downloadFileUrl(currentWorkspace, selectedFile);
|
||||
}, [selectedFile, currentWorkspace]);
|
||||
|
||||
const formatFileSize = (value: string): string => {
|
||||
const bytes = new Blob([value]).size;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -218,7 +237,7 @@ export function FileBrowserModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="file-browser-actions">
|
||||
{hasChanges && (
|
||||
{!imageSrc && hasChanges && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -241,23 +260,35 @@ export function FileBrowserModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editorError && (
|
||||
{editorError && !imageSrc && (
|
||||
<div className="file-browser-error-banner">{editorError}</div>
|
||||
)}
|
||||
|
||||
<div className="file-editor-wrapper">
|
||||
<FileEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
filePath={selectedFile}
|
||||
readOnly={isBinaryFile(selectedFile)}
|
||||
/>
|
||||
</div>
|
||||
{imageSrc ? (
|
||||
<div className="file-browser-image-preview">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={selectedFile ?? ""}
|
||||
className="file-browser-image"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="file-editor-wrapper">
|
||||
<FileEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
filePath={selectedFile}
|
||||
readOnly={isBinaryFile(selectedFile)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-browser-footer">
|
||||
<span>{formatFileSize(content)}</span>
|
||||
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
|
||||
</div>
|
||||
{!imageSrc && (
|
||||
<div className="file-browser-footer">
|
||||
<span>{formatFileSize(content)}</span>
|
||||
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="file-browser-placeholder">
|
||||
|
||||
@@ -11928,6 +11928,26 @@ html .column.drag-over * {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-browser-image-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.file-browser-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.file-browser-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -12059,6 +12079,15 @@ html .column.drag-over * {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.file-browser-image-preview {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.file-browser-image {
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Planning Mode Styles === */
|
||||
|
||||
Reference in New Issue
Block a user