feat(FN-1495): merge fusion/fn-1495

This commit is contained in:
gsxdsm
2026-04-11 09:01:29 -07:00
parent b40c9b5406
commit 05a310dc07
3 changed files with 295 additions and 16 deletions

View File

@@ -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");

View File

@@ -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">

View File

@@ -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 === */