From 2076ab76fab069df58a7b811fb9a28cd791337b4 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 12:53:25 +0000 Subject: [PATCH] Mobile FileBrowser: stacked cards for file list (Slice 4) Below md, the file table renders as MobileCardRow cards: name as primary, plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows navigate into the directory; file rows select for ffprobe preview). No pagination needed (FileBrowser does not paginate). The ext column is omitted from the card -- the extension is already visible in the filename itself, so it's redundant on mobile and would waste card space. Path bar / breadcrumbs / Open / Refresh live outside the table and already stack on mobile via existing md:flex-row. ffprobe and Jobs sections are unaffected. Desktop (md+) is byte-for-byte identical: the isMobile===false branch renders the same DataTable with the same props. Tests: 4 new covering mobile card render + dir-tap navigation + path controls present + desktop DataTable. matchMedia mocked per-breakpoint. 98 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4). --- frontend/src/pages/FileBrowser.impl.tsx | 63 +++++++++++---- .../src/pages/__tests__/FileBrowser.test.tsx | 78 +++++++++++++++++++ 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/FileBrowser.impl.tsx b/frontend/src/pages/FileBrowser.impl.tsx index f2b873c..7fb2434 100644 --- a/frontend/src/pages/FileBrowser.impl.tsx +++ b/frontend/src/pages/FileBrowser.impl.tsx @@ -3,6 +3,10 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import type { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { DataTable } from "@/components/ui/data-table"; +import { + MobileCardRow, + type MobileCardField, +} from "@/components/ui/mobile-card"; import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -24,6 +28,7 @@ import { useRunJob, } from "../hooks/useFiles"; import { usePersistentState } from "../hooks/usePersistentState"; +import { useIsMobile } from "../hooks/useIsMobile"; import { useMonitoringSettings } from "../hooks/useSettings"; import { SectionCard } from "../components/SectionCard"; import { TabbedCard } from "../components/TabbedCard"; @@ -182,6 +187,18 @@ const fileColumns: ColumnDef[] = [ }, ]; +// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields. +// Name is the primary identifier; type distinguishes dir/file/up at a glance; +// size and modified give the at-a-glance info a user browsing files on a phone +// needs. Ext is redundant with the name on mobile (the extension is visible in +// the filename itself). See OpenSpec change `mobile-responsive-parity`. +const fileCardFields: MobileCardField[] = [ + { key: "name", label: "Name", render: (r) => r.name, primary: true }, + { key: "type", label: "Type", render: (r) => r.type }, + { key: "size", label: "Size", render: (r) => r.size || "-" }, + { key: "modified", label: "Modified", render: (r) => r.modified || "-" }, +]; + const FILE_BROWSER_STATE_KEY = "manage.files.browserState"; type FileBrowserState = { @@ -501,6 +518,7 @@ function InfoAlert({ children }: { children: React.ReactNode }) { export function FileBrowser() { const [searchParams, setSearchParams] = useSearchParams(); + const isMobile = useIsMobile(); const [columnVisibility, setColumnVisibility] = useState< Record >({}); @@ -725,23 +743,34 @@ export function FileBrowser() { )}
- row.id} - enableRowSelection - rowSelection={rowSelection} - onRowSelectionChange={handleSelectionChange} - onRowClick={handleRowClick} - enableColumnVisibilityToggle - columnVisibility={columnVisibility} - onColumnVisibilityChange={setColumnVisibility} - emptyMessage={ - isLoading - ? "Loading directory..." - : "This directory is empty." - } - /> + {isMobile ? ( +
+ row.id} + onRowClick={handleRowClick} + /> +
+ ) : ( + row.id} + enableRowSelection + rowSelection={rowSelection} + onRowSelectionChange={handleSelectionChange} + onRowClick={handleRowClick} + enableColumnVisibilityToggle + columnVisibility={columnVisibility} + onColumnVisibilityChange={setColumnVisibility} + emptyMessage={ + isLoading + ? "Loading directory..." + : "This directory is empty." + } + /> + )}
diff --git a/frontend/src/pages/__tests__/FileBrowser.test.tsx b/frontend/src/pages/__tests__/FileBrowser.test.tsx index 64c66dd..890e9de 100644 --- a/frontend/src/pages/__tests__/FileBrowser.test.tsx +++ b/frontend/src/pages/__tests__/FileBrowser.test.tsx @@ -8,6 +8,7 @@ import type { DirectoryListing, MonitoringMachine } from "../../types"; // so the selectedPath / currentDir state never leaks across cases. beforeEach(() => { window.localStorage.clear(); + setMatchMedia(false); }); function machineFixture( @@ -77,6 +78,30 @@ beforeEach(() => { ]); }); +/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */ +function setMatchMedia(matches: boolean) { + const listeners: ((e: MediaQueryListEvent) => void)[] = []; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("768") ? matches : false, + media: query, + onchange: null, + addEventListener: ( + _evt: string, + listener: (e: MediaQueryListEvent) => void, + ) => listeners.push(listener), + removeEventListener: ( + _evt: string, + listener: (e: MediaQueryListEvent) => void, + ) => { + const idx = listeners.indexOf(listener); + if (idx >= 0) listeners.splice(idx, 1); + }, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => { it("renders the 5 locked columns (type/name/ext/size/modified)", () => { render(); @@ -118,3 +143,56 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => { expect(screen.queryByText(/Selected:/)).toBeNull(); }); }); + +describe("FileBrowser (mobile card layout — slice 4)", () => { + it("renders cards with file/dir name as primary below md", () => { + setMatchMedia(true); + render(); + + // Card titles (the 'name' field rendered as primary). + expect(screen.getByText("movies")).toBeInTheDocument(); + expect(screen.getByText("video.mkv")).toBeInTheDocument(); + expect(screen.getByText("notes.txt")).toBeInTheDocument(); + + // Desktop table column headers must NOT render. + const headers = screen.queryAllByRole("columnheader"); + expect(headers).toHaveLength(0); + }); + + it("tapping a directory card navigates into it", async () => { + setMatchMedia(true); + render(); + + // Directory card is a button wrapping the 'movies' text. + await userEvent.click(screen.getByText("movies")); + + // After navigating into /movies, the status caption shows the new cwd + // and NO 'Selected:' segment (directories are opened, not selected). + expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument(); + expect(screen.queryByText(/Selected:/)).toBeNull(); + }); + + it("renders the path/breadcrumb controls on mobile", () => { + setMatchMedia(true); + render(); + + // The 'Remote path' label and its input are part of the Browser section + // card (outside the table), so they render on both breakpoints. + expect(screen.getByLabelText("Remote path")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + }); + + it("renders the DataTable at desktop width (1280px)", () => { + setMatchMedia(false); + render(); + + // Desktop path: table column headers are present. + const headers = screen + .getAllByRole("columnheader") + .map((h) => h.textContent); + expect(headers).toEqual( + expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]), + ); + }); +});