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).
This commit is contained in:
Developer
2026-06-26 12:53:25 +00:00
parent 2e3e7b3850
commit 2076ab76fa
2 changed files with 124 additions and 17 deletions
+46 -17
View File
@@ -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<DisplayRow>[] = [
},
];
// 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<DisplayRow>[] = [
{ 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<string, boolean>
>({});
@@ -725,23 +743,34 @@ export function FileBrowser() {
</Alert>
)}
<div className="rounded-lg border bg-card">
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading
? "Loading directory..."
: "This directory is empty."
}
/>
{isMobile ? (
<div className="p-4">
<MobileCardRow
rows={rows}
fields={fileCardFields}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
/>
</div>
) : (
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading
? "Loading directory..."
: "This directory is empty."
}
/>
)}
</div>
</div>
</SectionCard>
@@ -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(<FileBrowser />);
@@ -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(<FileBrowser />);
// 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(<FileBrowser />);
// 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(<FileBrowser />);
// 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(<FileBrowser />);
// 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"]),
);
});
});