import type { ComponentProps } from "react"; 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"); vi.mock("../../hooks/useViewportMode", () => { const mode = () => (window.innerWidth <= 768 ? "mobile" : "desktop"); return { MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", getViewportMode: mode, isMobileViewport: () => mode() === "mobile", useViewportMode: mode, }; }); const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser); const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor); const mockUseWorkspaces = vi.mocked(workspacesHook.useWorkspaces); function mockSelectionRect() { const rect = new DOMRect(10, 20, 80, 12); Object.defineProperty(Range.prototype, "getBoundingClientRect", { configurable: true, value: vi.fn(() => rect), }); Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList), }); } function selectNodeText(node: Node) { const range = document.createRange(); range.selectNodeContents(node); const selection = document.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); document.dispatchEvent(new Event("selectionchange")); } 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("sends selected code text from the embedded editor to a new task description", async () => { mockSelectionRect(); const onSendSelectionToTask = vi.fn(); render( , ); fireEvent.click(screen.getByText("file1.ts")); await waitFor(() => expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument()); selectNodeText(document.querySelector(".cm-content") as Node); fireEvent.click(await screen.findByRole("button", { name: /add a comment/i })); fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Investigate this file." } }); fireEvent.click(screen.getByRole("button", { name: /send to new task/i })); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: file1.ts")); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("console.log")); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Investigate this file.")); }); it("sends selected markdown preview text from the embedded editor to a new task description", async () => { mockSelectionRect(); const onSendSelectionToTask = vi.fn(); mockUseWorkspaceFileEditor.mockReturnValue({ ...defaultEditorState, content: "# Heading\n\nPreview body", originalContent: "# Heading\n\nPreview body", }); render( , ); await waitFor(() => expect(screen.getAllByText("README.md").length).toBeGreaterThan(0)); fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i })); fireEvent.click(screen.getByRole("button", { name: /preview mode/i })); selectNodeText(await screen.findByText("Preview body")); fireEvent.click(await screen.findByRole("button", { name: /add a comment/i })); fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Turn preview note into work." } }); fireEvent.click(screen.getByRole("button", { name: /send to new task/i })); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: README.md")); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Preview body")); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Turn preview note into work.")); }); it("opens with an initial file selected", async () => { render( , ); await waitFor(() => { expect(screen.getAllByText("packages/dashboard/app/App.tsx").length).toBeGreaterThan(0); }); expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app"); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", 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("shows full editor toolbar actions directly in the narrow mobile file view", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); mockUseWorkspaceFileEditor.mockReturnValue({ ...defaultEditorState, content: "# Heading\n\nBody", originalContent: "# Heading\n\nBody", }); render( , ); fireEvent(window, new Event("resize")); await waitFor(() => { expect(screen.getByLabelText("Back to file list")).toBeInTheDocument(); }); expect(screen.queryByRole("button", { name: /toggle editor options/i })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /edit mode/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /preview mode/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument(); }); it("switches between mobile editor layout and two-pane layout from floating modal width", async () => { const originalResizeObserver = globalThis.ResizeObserver; const originalWindowResizeObserver = window.ResizeObserver; const observedElements: Array<{ element: Element; callback: ResizeObserverCallback }> = []; const MockResizeObserver = class ResizeObserver { private callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback; } observe(element: Element) { observedElements.push({ element, callback: this.callback }); } unobserve() {} disconnect() {} }; globalThis.ResizeObserver = MockResizeObserver; window.ResizeObserver = MockResizeObserver; try { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024, }); render( , ); const modal = document.querySelector(".file-browser-modal") as HTMLElement; expect(modal).toBeInTheDocument(); await waitFor(() => expect(observedElements.some((entry) => entry.element === modal)).toBe(true)); Object.defineProperty(modal, "getBoundingClientRect", { configurable: true, value: () => ({ width: 420, height: 700, top: 0, left: 0, bottom: 700, right: 420, x: 0, y: 0, toJSON: () => ({}) }), }); await act(async () => { observedElements.find((entry) => entry.element === modal)?.callback([] as ResizeObserverEntry[], {} as ResizeObserver); }); await waitFor(() => { expect(modal).toHaveClass("file-browser-modal--narrow"); }); expect(document.querySelector(".file-browser-content.mobile.active")).not.toBeNull(); expect(document.querySelector(".file-browser-sidebar.mobile.active")).toBeNull(); Object.defineProperty(modal, "getBoundingClientRect", { configurable: true, value: () => ({ width: 980, height: 700, top: 0, left: 0, bottom: 700, right: 980, x: 0, y: 0, toJSON: () => ({}) }), }); await act(async () => { observedElements.find((entry) => entry.element === modal)?.callback([] as ResizeObserverEntry[], {} as ResizeObserver); }); await waitFor(() => { expect(modal).not.toHaveClass("file-browser-modal--narrow"); }); expect(document.querySelector(".file-browser-content.mobile")).toBeNull(); expect(document.querySelector(".file-browser-sidebar.mobile")).toBeNull(); expect(screen.getByRole("separator", { name: "Resize sidebar" })).toBeInTheDocument(); } finally { globalThis.ResizeObserver = originalResizeObserver; window.ResizeObserver = originalWindowResizeObserver; } }); it("keeps mobile close button visible and clickable", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); render( , ); fireEvent(window, new Event("resize")); const closeButton = document.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" }, ], }); 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 = document.querySelector(".file-browser-header-path"); expect(pathEl).toBeInTheDocument(); expect(pathEl?.textContent).toBe(longFileName); }); const closeButton = document.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[^{]*\(max-width: 768px\)[^{]*\{/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("keeps the mobile file modal header easy to drag by touch", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const cssContent = loadAllAppCss(); const baseHeaderRules = cssContent.match(/\.file-browser-modal-header\s*\{([^}]*)\}/)?.[1] ?? ""; expect(baseHeaderRules).toContain("touch-action: none"); expect(baseHeaderRules).toContain("min-height: 48px"); function extractMobileMediaBlocks(content: string): string { const blocks: string[] = []; const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/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); const mobileHeaderRules = mobileBlock.match(/\.file-browser-modal-header\s*\{([^}]*)\}/)?.[1] ?? ""; const mobileHandleRules = mobileBlock.match(/\.file-browser-modal-header::before\s*\{([^}]*)\}/)?.[1] ?? ""; expect(mobileHeaderRules).toContain("min-height: 56px"); expect(mobileHeaderRules).toContain("padding-block: calc(var(--space-md) + var(--space-xs)) var(--space-md)"); expect(mobileHandleRules).toContain("position: absolute"); expect(mobileHandleRules).toContain("background: color-mix(in srgb, var(--text-muted) 44%, transparent)"); }); 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", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = document.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", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = document.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", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = document.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", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = document.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", () => { render( , ); const handle = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebar = document.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("browser-native file previews", () => { const renderWithEntries = (entries: typeof defaultBrowserState.entries, props: Partial> = {}) => { mockUseWorkspaceFileBrowser.mockReturnValue({ ...defaultBrowserState, entries, }); return render( , ); }; const selectFile = async (name: string) => { await act(async () => { fireEvent.click(screen.getByText(name)); }); }; it.each([ { name: "screenshot.png", role: "img" as const, selector: "img.file-browser-preview-media--image", attribute: "src", }, { name: "clip.mp4", role: null, selector: "video.file-browser-preview-media--video", attribute: "src", }, { name: "voice.mp3", role: null, selector: "audio.file-browser-preview-media--audio", attribute: "src", }, { name: "manual.pdf", role: null, selector: "iframe.file-browser-preview-media--pdf", attribute: "src", }, ])("renders $name with the native project preview element", async ({ name, role, selector, attribute }) => { mockUseWorkspaceFileEditor.mockReturnValue({ ...defaultEditorState, hasChanges: true, }); renderWithEntries([ { name, type: "file" as const, size: 102400, mtime: "2024-01-01" }, ]); await selectFile(name); const preview = role === "img" ? screen.getByRole(role, { name }) : document.querySelector(selector); expect(preview).toBeInTheDocument(); expect(preview).toHaveAttribute(attribute, expect.stringContaining(encodeURIComponent(name))); expect(preview).toHaveAttribute(attribute, expect.stringContaining("workspace=project")); expect(preview).toHaveAttribute(attribute, expect.stringContaining("inline=1")); if (selector.startsWith("video") || selector.startsWith("audio")) { expect(preview).toHaveAttribute("controls"); expect(preview).toHaveAttribute("aria-label", `Preview for ${name}`); } if (selector.startsWith("iframe")) { expect(preview).toHaveAttribute("title", `Preview for ${name}`); } expect(screen.getByText("Preview only")).toBeInTheDocument(); expect(screen.queryByText(/Binary file — read only/)).not.toBeInTheDocument(); expect(screen.queryByLabelText(new RegExp(`Editor for ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`))).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /toggle editor options/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Discard/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument(); expect(document.querySelector(".file-editor-wrapper")).not.toBeInTheDocument(); expect(document.querySelector(".file-browser-footer")).not.toBeInTheDocument(); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", name, false, undefined); }); it("renders task-workspace preview URLs with project scoping", async () => { renderWithEntries([ { name: "movie.mov", type: "file" as const, size: 204800, mtime: "2024-01-01" }, ], { initialWorkspace: "FN-001", projectId: "proj-1", }); await selectFile("movie.mov"); const video = document.querySelector("video.file-browser-preview-media--video"); expect(video).toBeInTheDocument(); expect(video).toHaveAttribute("src", expect.stringContaining("workspace=FN-001")); expect(video).toHaveAttribute("src", expect.stringContaining("projectId=proj-1")); expect(video).toHaveAttribute("src", expect.stringContaining("inline=1")); expect(video).toHaveAttribute("src", expect.stringContaining("movie.mov")); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1"); }); it("previews uppercase and nested PDF paths without loading editor content", async () => { render( , ); await waitFor(() => expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument()); const pdf = document.querySelector("iframe.file-browser-preview-media--pdf"); expect(pdf).toHaveAttribute("src", expect.stringContaining(encodeURIComponent("docs/MANUAL.PDF"))); expect(pdf).toHaveAttribute("src", expect.stringContaining("inline=1")); expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF"); expect(mockSetPath).toHaveBeenCalledWith("docs"); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined); }); it("keeps text files editable with save and discard controls", async () => { mockUseWorkspaceFileEditor.mockReturnValue({ ...defaultEditorState, hasChanges: true, }); renderWithEntries([ { name: "file1.ts", type: "file" as const, size: 1024, mtime: "2024-01-01" }, ]); await selectFile("file1.ts"); expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Discard/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument(); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined); }); it("keeps unknown binary files in the read-only editor fallback", async () => { renderWithEntries([ { name: "archive.zip", type: "file" as const, size: 1024, mtime: "2024-01-01" }, ]); await selectFile("archive.zip"); expect(screen.getByText(/Binary file — read only/)).toBeInTheDocument(); expect(screen.getByLabelText("Editor for archive.zip")).toBeInTheDocument(); expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument(); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "archive.zip", true, undefined); }); it("keeps the no-selected-file placeholder until a previewable file is selected", async () => { renderWithEntries([ { name: "voice.mp3", type: "file" as const, size: 1024, mtime: "2024-01-01" }, ]); expect(screen.getByText("Select a file to edit")).toBeInTheDocument(); expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument(); await selectFile("voice.mp3"); expect(screen.queryByText("Select a file to edit")).not.toBeInTheDocument(); expect(document.querySelector("audio.file-browser-preview-media--audio")).toBeInTheDocument(); }); it("updates preview-only state across repeated selections", async () => { renderWithEntries([ { name: "manual.pdf", type: "file" as const, size: 1024, mtime: "2024-01-01" }, { name: "clip.mp4", type: "file" as const, size: 1024, mtime: "2024-01-01" }, ]); await selectFile("manual.pdf"); expect(document.querySelector("iframe.file-browser-preview-media--pdf")).toBeInTheDocument(); await selectFile("clip.mp4"); expect(document.querySelector("iframe.file-browser-preview-media--pdf")).not.toBeInTheDocument(); const video = document.querySelector("video.file-browser-preview-media--video"); expect(video).toBeInTheDocument(); expect(video).toHaveAttribute("src", expect.stringContaining("clip.mp4")); expect(video).toHaveAttribute("src", expect.stringContaining("inline=1")); expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined); }); it("renders preview-only files in the mobile editor pane with back navigation", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375, }); renderWithEntries([ { name: "voice.mp3", type: "file" as const, size: 1024, mtime: "2024-01-01" }, ]); fireEvent(window, new Event("resize")); await selectFile("voice.mp3"); expect(screen.getByLabelText("Back to file list")).toBeInTheDocument(); const audio = document.querySelector("audio.file-browser-preview-media--audio"); expect(audio).toBeInTheDocument(); expect(audio).toHaveAttribute("src", expect.stringContaining("inline=1")); expect(document.querySelector(".file-browser-content.mobile.active")).toBeInTheDocument(); }); }); describe("line number toggle", () => { const clickFileEntry = async (name: string) => { const fileEntry = screen.getAllByText(name).find((element) => element.classList.contains("file-node-name")); expect(fileEntry).toBeTruthy(); await act(async () => { fireEvent.click(fileEntry!); }); }; it("renders an editor toggle and persists preference per project", async () => { render( , ); await clickFileEntry("file1.ts"); fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i })); 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", async () => { localStorage.setItem("kb:proj-a:kb-files-line-numbers", "true"); localStorage.setItem("kb:proj-b:kb-files-line-numbers", "false"); const { rerender } = render( , ); await clickFileEntry("file1.ts"); fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i })); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "true"); rerender( , ); await clickFileEntry("file1.ts"); if (!screen.queryByRole("button", { name: /toggle line numbers/i })) { fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i })); } 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( , ); await act(async () => { fireEvent.click(screen.getByText("editable.ts")); }); fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i })); fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i })); expect(document.querySelector(".cm-gutters")).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByText("readme.pdf")); }); expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument(); expect(document.querySelector(".cm-gutters")).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[^{]*\(max-width: 768px\)[^{]*\{/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"); }); }); });