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"; import { FileBrowserModal } from "../FileBrowserModal"; import * as workspaceBrowserHook from "../../hooks/useWorkspaceFileBrowser"; import * as workspaceEditorHook from "../../hooks/useWorkspaceFileEditor"; import * as workspacesHook from "../../hooks/useWorkspaces"; vi.mock("../../hooks/useWorkspaceFileBrowser"); vi.mock("../../hooks/useWorkspaceFileEditor"); vi.mock("../../hooks/useWorkspaces"); const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser); const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor); const mockUseWorkspaces = vi.mocked(workspacesHook.useWorkspaces); describe("FileBrowserModal", () => { const mockOnClose = vi.fn(); const mockOnWorkspaceChange = vi.fn(); const mockSave = vi.fn().mockResolvedValue(undefined); const mockSetContent = vi.fn(); const mockSetPath = vi.fn(); const mockRefresh = vi.fn(); const defaultBrowserState = { entries: [ { name: "file1.ts", type: "file" as const, size: 1024, mtime: "2024-01-01" }, { name: "folder1", type: "directory" as const, mtime: "2024-01-01" }, ], currentPath: ".", setPath: mockSetPath, loading: false, error: null, refresh: mockRefresh, }; const defaultEditorState = { content: "console.log('hello');", setContent: mockSetContent, originalContent: "console.log('hello');", loading: false, saving: false, error: null, save: mockSave, hasChanges: false, mtime: "2024-01-01", }; beforeEach(() => { vi.resetAllMocks(); mockUseWorkspaceFileBrowser.mockReturnValue(defaultBrowserState); mockUseWorkspaceFileEditor.mockReturnValue(defaultEditorState); mockUseWorkspaces.mockReturnValue({ projectName: "kb", workspaces: [ { id: "FN-001", label: "FN-001", title: "Task One", worktree: "/repo/.worktrees/kb-001", kind: "task" }, { id: "FN-002", label: "FN-002", title: "Task Two", worktree: "/repo/.worktrees/kb-002", kind: "task" }, ], loading: false, error: null, }); Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024, }); }); afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); }); it("renders project-root modal title and workspace selector", () => { render( , ); expect(screen.getByText("Files — Project")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /kb/i })).toBeInTheDocument(); expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true, undefined); }); it("opens a file in the editor when selected", async () => { render( , ); fireEvent.click(screen.getByText("file1.ts")); await waitFor(() => { expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument(); }); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined); }); it("switches workspace and notifies parent", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button", { name: /kb/i })); await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i })); expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002"); }); it("shows back button in mobile editor view", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); render( , ); fireEvent(window, new Event("resize")); fireEvent.click(screen.getByText("file1.ts")); await waitFor(() => { expect(screen.getByLabelText("Back to file list")).toBeInTheDocument(); }); }); it("keeps mobile close button visible and clickable", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); const { container } = render( , ); fireEvent(window, new Event("resize")); const closeButton = container.querySelector("button.modal-close"); expect(closeButton).toBeInTheDocument(); expect(closeButton).toBeVisible(); fireEvent.click(closeButton!); await waitFor(() => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); }); it("close button is visible on mobile after selecting a file with a long path", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); // Provide a file with a long path name const longFileName = "packages/dashboard/app/components/SomeVeryLongComponentName.tsx"; mockUseWorkspaceFileBrowser.mockReturnValue({ ...defaultBrowserState, entries: [ { name: longFileName, type: "file" as const, size: 2048, mtime: "2024-01-01" }, ], }); const { container } = render( , ); fireEvent(window, new Event("resize")); // Select the long-named file fireEvent.click(screen.getByText(longFileName)); // Verify the file path appears in the header await waitFor(() => { const pathEl = container.querySelector(".file-browser-header-path"); expect(pathEl).toBeInTheDocument(); expect(pathEl?.textContent).toBe(longFileName); }); const closeButton = container.querySelector("button.modal-close"); expect(closeButton).toBeInTheDocument(); expect(closeButton).toBeVisible(); // Clicking the close button should trigger onClose fireEvent.click(closeButton!); await waitFor(() => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); }); it("long file path is truncated on mobile", async () => { // Read CSS file directly to verify the overflow/ellipsis rules // (JSDOM doesn't apply stylesheets, so computed style checks won't work) const { loadAllAppCss } = await import("../../test/cssFixture"); const cssContent = loadAllAppCss(); // Extract mobile media query blocks function extractMobileMediaBlocks(content: string): string { const blocks: string[] = []; const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g; let match; while ((match = regex.exec(content)) !== null) { const startIdx = match.index + match[0].length; let braceCount = 1; let endIdx = startIdx; while (braceCount > 0 && endIdx < content.length) { if (content[endIdx] === "{") braceCount += 1; if (content[endIdx] === "}") braceCount -= 1; endIdx += 1; } if (braceCount === 0) { blocks.push(content.slice(startIdx, endIdx - 1)); } } return blocks.join("\n"); } const mobileBlock = extractMobileMediaBlocks(cssContent); // Find the file-browser-header-path rule within mobile blocks const pathMatch = mobileBlock.match( /\.file-browser-header-path\s*\{([^}]*)\}/, ); expect(pathMatch).not.toBeNull(); const pathRules = pathMatch![1]; expect(pathRules).toContain("text-overflow: ellipsis"); expect(pathRules).toContain("white-space: nowrap"); expect(pathRules).toContain("overflow: hidden"); expect(pathRules).toContain("max-width: 50vw"); }); it("closes on Escape and saves on Cmd+S", () => { mockUseWorkspaceFileEditor.mockReturnValue({ ...defaultEditorState, hasChanges: true, }); render( , ); fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "s", metaKey: true }); expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockSave).toHaveBeenCalledTimes(1); }); it("renders hidden files and directories from the file listing", () => { mockUseWorkspaceFileBrowser.mockReturnValue({ ...defaultBrowserState, entries: [ { name: ".env.example", type: "file", size: 42, mtime: "2024-01-01" }, { name: ".github", type: "directory", mtime: "2024-01-01" }, { name: "src", type: "directory", mtime: "2024-01-01" }, ], }); render( , ); expect(screen.getByText(".env.example")).toBeInTheDocument(); expect(screen.getByText(".github")).toBeInTheDocument(); expect(screen.getByText("src")).toBeInTheDocument(); }); describe("resizable sidebar split", () => { it("renders desktop resize handle with separator ARIA attributes", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); expect(handle).toHaveAttribute("aria-orientation", "vertical"); expect(handle).toHaveAttribute("aria-valuemin", "180"); expect(handle).toHaveAttribute("aria-valuemax", "500"); expect(handle).toHaveAttribute("aria-valuenow", "280"); expect(handle).toHaveAttribute("tabindex", "0"); }); it("updates sidebar width while dragging the resize handle", () => { const { container } = render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = container.querySelector(".file-browser-sidebar"); expect(sidebar).not.toBeNull(); fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 }); fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 }); expect(sidebar).toHaveStyle({ width: "360px" }); expect(handle).toHaveAttribute("aria-valuenow", "360"); }); it("does not render resize handle in mobile view", () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); render( , ); fireEvent(window, new Event("resize")); expect(screen.queryByRole("separator", { name: "Resize sidebar" })).not.toBeInTheDocument(); }); it("clamps sidebar width between min and max bounds", () => { const { container } = render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = container.querySelector(".file-browser-sidebar"); expect(sidebar).not.toBeNull(); fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 }); fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 }); expect(sidebar).toHaveStyle({ width: "180px" }); fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 }); expect(sidebar).toHaveStyle({ width: "500px" }); expect(handle).toHaveAttribute("aria-valuenow", "500"); }); it("persists final sidebar width to localStorage on pointer up", () => { let onPointerMove: ((event: PointerEvent) => void) | null = null; let onPointerUp: ((event: PointerEvent) => void) | null = null; const addEventListenerSpy = vi.spyOn(document, "addEventListener"); addEventListenerSpy.mockImplementation((type, listener, options) => { if (type === "pointermove") { onPointerMove = listener as (event: PointerEvent) => void; } if (type === "pointerup") { onPointerUp = listener as (event: PointerEvent) => void; } return EventTarget.prototype.addEventListener.call(document, type, listener as EventListener, options); }); render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 }); expect(onPointerMove).not.toBeNull(); expect(onPointerUp).not.toBeNull(); act(() => { onPointerMove?.({ clientX: 345, pointerId: 1 } as PointerEvent); onPointerUp?.({ pointerId: 1 } as PointerEvent); }); expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("345"); }); it("supports keyboard resize with arrow keys and persists updated width", () => { const { container } = render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = container.querySelector(".file-browser-sidebar"); expect(sidebar).not.toBeNull(); fireEvent.keyDown(handle, { key: "ArrowRight" }); expect(sidebar).toHaveStyle({ width: "300px" }); expect(handle).toHaveAttribute("aria-valuenow", "300"); expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("300"); fireEvent.keyDown(handle, { key: "ArrowLeft" }); expect(sidebar).toHaveStyle({ width: "280px" }); expect(handle).toHaveAttribute("aria-valuenow", "280"); expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("280"); }); it("clamps keyboard resize within min and max bounds", () => { const { container } = render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = container.querySelector(".file-browser-sidebar"); expect(sidebar).not.toBeNull(); for (let i = 0; i < 30; i += 1) { fireEvent.keyDown(handle, { key: "ArrowLeft" }); } expect(sidebar).toHaveStyle({ width: "180px" }); expect(handle).toHaveAttribute("aria-valuenow", "180"); for (let i = 0; i < 30; i += 1) { fireEvent.keyDown(handle, { key: "ArrowRight" }); } expect(sidebar).toHaveStyle({ width: "500px" }); expect(handle).toHaveAttribute("aria-valuenow", "500"); }); it("ignores non-arrow keys when resizing from keyboard", () => { const { container } = render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = container.querySelector(".file-browser-sidebar"); expect(sidebar).not.toBeNull(); fireEvent.keyDown(handle, { key: "Enter" }); expect(sidebar).toHaveStyle({ width: "280px" }); expect(handle).toHaveAttribute("aria-valuenow", "280"); expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBeNull(); }); it("defines focus-visible styling for the resize handle", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); expect(css).toMatch(/\.file-browser-resize-handle:focus-visible\s*\{[^}]*box-shadow:\s*var\(--focus-ring-strong\);/); }); }); 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( , ); // 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); // 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( , ); 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( , ); 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("line number toggle", () => { it("renders a header toggle and persists preference per project", async () => { render( , ); const toggle = screen.getByRole("button", { name: /toggle line numbers/i }); expect(toggle).toHaveAttribute("aria-pressed", "false"); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-pressed", "true"); expect(localStorage.getItem("kb:proj-1:kb-files-line-numbers")).toBe("true"); }); it("loads persisted preference when project changes", () => { localStorage.setItem("kb:proj-a:kb-files-line-numbers", "true"); localStorage.setItem("kb:proj-b:kb-files-line-numbers", "false"); const { rerender } = render( , ); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "true"); rerender( , ); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "false"); }); it("only shows gutter for editable text files", async () => { mockUseWorkspaceFileBrowser.mockReturnValue({ ...defaultBrowserState, entries: [ { name: "editable.ts", type: "file" as const, size: 64, mtime: "2024-01-01" }, { name: "readme.pdf", type: "file" as const, size: 64, mtime: "2024-01-01" }, ], }); render( , ); fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i })); await act(async () => { fireEvent.click(screen.getByText("editable.ts")); }); expect(document.querySelector(".file-editor-line-numbers")).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByText("readme.pdf")); }); expect(document.querySelector(".file-editor-line-numbers")).not.toBeInTheDocument(); }); }); describe("modal height constraint regression", () => { it("max-height uses calc() to stay within viewport padding", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); // Extract the first .file-browser-modal block (desktop base styles) // Match from ".file-browser-modal {" to its closing "}" const blockMatch = css.match( /\.file-browser-modal\s*\{[^}]*max-height:\s*([^;]+);/, ); expect(blockMatch).toBeTruthy(); const maxHeightValue = blockMatch![1].trim(); // The max-height must use calc() with the overlay-padding-top variable // so the modal fits within the visible viewport. We accept either // 100vh or 100dvh (the latter accounts for mobile dynamic viewport // chrome and is preferred for the resize-aware modals). expect(maxHeightValue).toContain("calc("); expect(maxHeightValue).toContain("--overlay-padding-top"); expect(maxHeightValue).toMatch(/100d?vh/); }); it("height and max-height together do not exceed viewport on desktop", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); const blockMatch = css.match( /\.file-browser-modal\s*\{([^}]*)\}/, ); expect(blockMatch).toBeTruthy(); const block = blockMatch![1]; // Extract height value const heightMatch = block.match(/height:\s*([^;]+);/); expect(heightMatch).toBeTruthy(); const heightValue = heightMatch![1].trim(); // height should be a reasonable vh value (≤ 85vh for desktop) const heightNum = parseFloat(heightValue); expect(heightNum).toBeGreaterThan(0); expect(heightNum).toBeLessThanOrEqual(85); // max-height must be present and use calc() const maxHeightMatch = block.match(/max-height:\s*([^;]+);/); expect(maxHeightMatch).toBeTruthy(); expect(maxHeightMatch![1].trim()).toContain("calc("); }); it("mobile styles use 100dvh for full-screen behavior", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); // Extract mobile media query blocks (similar to existing pattern) function extractMobileMediaBlocks(content: string): string { const blocks: string[] = []; const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g; let match; while ((match = regex.exec(content)) !== null) { const startIdx = match.index + match[0].length; let braceCount = 1; let endIdx = startIdx; while (braceCount > 0 && endIdx < content.length) { if (content[endIdx] === "{") braceCount += 1; if (content[endIdx] === "}") braceCount -= 1; endIdx += 1; } if (braceCount === 0) { blocks.push(content.slice(startIdx, endIdx - 1)); } } return blocks.join("\n"); } const mobileBlock = extractMobileMediaBlocks(css); // Find the file-browser-modal rule within mobile blocks const modalMatch = mobileBlock.match( /\.file-browser-modal\s*\{([^}]*)\}/, ); expect(modalMatch).not.toBeNull(); const modalRules = modalMatch![1]; // Mobile should use 100dvh for height/max-height expect(modalRules).toContain("100dvh"); }); }); });