File Manager
alphav0.1.3Finder-style file browser — grid and list views, marquee multi-select, cut copy paste, drag-and-drop, and a shared clipboard primitive.
Context
Pairs with `file-tree` (the sidebar primitive) for the dual-pane Finder layout — drop `<FileTree>` into `<FileManager>`'s `sidebar` slot. Use anywhere a current-folder content view is needed: asset libraries, document workspaces, attachment managers, S3-bucket explorers. Controlled-data, object-shape callbacks, lazy children. The new `<FileClipboardProvider>` syncs cut/copy/paste across multiple instances.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/file-managerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/file-manager-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Demo source
"use client"; import { useCallback, useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { FileManager } from "./file-manager";import { dummyFsNodes, dummyFlatGrid, dummyLargeFolder,} from "./dummy-data";import { mergeLoadedChildren } from "./lib/tree-utils";import { FileClipboardProvider } from "../_shared/file-clipboard";import type { FsNode } from "./types"; function Frame({ children }: { children: React.ReactNode }) { return ( <div className="h-130 w-full overflow-hidden rounded-lg border border-border bg-card"> {children} </div> );} function StandaloneGridDemo() { return ( <Frame> <FileManager nodes={dummyFsNodes} defaultCurrentFolderId={null} title="Project" /> </Frame> );} function FlatGridDemo() { return ( <Frame> <FileManager nodes={dummyFlatGrid} defaultCurrentFolderId={null} title="Asset library" showBackForward={false} showUpButton={false} showPathBar={false} /> </Frame> );} function FullCrudDemo() { const [nodes, setNodes] = useState<FsNode[]>(dummyFsNodes); const [counter, setCounter] = useState(1); const [currentFolderId, setCurrentFolderId] = useState<string | null>(null); const handleCreate = useCallback( ({ parentId, type, }: { parentId: string | null; type: "file" | "folder"; }) => { const id = `new-${counter}`; setCounter((n) => n + 1); const newNode: FsNode = { id, name: type === "file" ? `untitled-${counter}.txt` : `New Folder ${counter}`, type, parentId, children: type === "folder" ? [] : undefined, }; const insert = (list: FsNode[]): FsNode[] => { if (parentId === null) return [...list, newNode]; return list.map((n) => { if (n.id === parentId) { return { ...n, children: [...(n.children ?? []), newNode] }; } if (n.children) return { ...n, children: insert(n.children) }; return n; }); }; setNodes(insert); }, [counter], ); const handleRename = useCallback( (args: { id: string; nextName: string }) => { const rename = (list: FsNode[]): FsNode[] => list.map((n) => { if (n.id === args.id) return { ...n, name: args.nextName }; if (n.children) return { ...n, children: rename(n.children) }; return n; }); setNodes(rename); }, [], ); const handleDelete = useCallback((args: { ids: string[] }) => { const ids = new Set(args.ids); const remove = (list: FsNode[]): FsNode[] => list .filter((n) => !ids.has(n.id)) .map((n) => n.children ? { ...n, children: remove(n.children) } : n, ); setNodes(remove); }, []); const moveAll = useCallback( ( args: { ids: string[]; targetId: string | null }, collected: FsNode[] = [], ): FsNode[] => { const moving = new Set(args.ids); const collect: FsNode[] = collected; const removeAndCollect = (list: FsNode[]): FsNode[] => list .filter((n) => { if (moving.has(n.id)) { collect.push(n); return false; } return true; }) .map((n) => n.children ? { ...n, children: removeAndCollect(n.children) } : n, ); const removed = removeAndCollect(nodes); if (args.targetId === null) return [...removed, ...collect]; const insertInside = (list: FsNode[]): FsNode[] => list.map((n) => { if (n.id === args.targetId) { return { ...n, children: [...(n.children ?? []), ...collect] }; } if (n.children) return { ...n, children: insertInside(n.children) }; return n; }); return insertInside(removed); }, [nodes], ); const handleMove = useCallback( (args: { ids: string[]; targetId: string | null }) => { setNodes(moveAll(args)); }, [moveAll], ); const handlePaste = useCallback( (args: { ids: string[]; kind: "cut" | "copy"; targetFolderId: string | null; }) => { if (args.kind === "cut") { setNodes(moveAll({ ids: args.ids, targetId: args.targetFolderId })); } else { // copy: clone with fresh ids const cloned: FsNode[] = []; const cloneNode = (n: FsNode): FsNode => ({ ...n, id: `${n.id}-copy-${counter}`, parentId: args.targetFolderId, children: n.children?.map(cloneNode), }); const collect = (list: FsNode[]) => { for (const n of list) { if (args.ids.includes(n.id)) cloned.push(cloneNode(n)); if (n.children) collect(n.children); } }; collect(nodes); setCounter((n) => n + 1); if (args.targetFolderId === null) { setNodes((prev) => [...prev, ...cloned]); } else { setNodes((prev) => prev.map(function attach(n: FsNode): FsNode { if (n.id === args.targetFolderId) { return { ...n, children: [...(n.children ?? []), ...cloned], }; } if (n.children) { return { ...n, children: n.children.map(attach) }; } return n; }), ); } } }, [moveAll, nodes, counter], ); return ( <Frame> <FileClipboardProvider> <FileManager nodes={nodes} currentFolderId={currentFolderId} onCurrentFolderChange={({ folderId }) => setCurrentFolderId(folderId)} title="Full CRUD" onOpen={({ node }) => alert(`Open: ${node.name}`)} onCreate={handleCreate} onRename={handleRename} onDelete={handleDelete} onMove={handleMove} onPaste={handlePaste} onRefresh={() => alert("Refresh!")} /> </FileClipboardProvider> </Frame> );} function LazyLoadDemo() { const [nodes, setNodes] = useState<FsNode[]>([ { id: "src", name: "src", type: "folder", parentId: null }, { id: "public", name: "public", type: "folder", parentId: null }, { id: "README.md", name: "README.md", type: "file", parentId: null }, ]); const [currentFolderId, setCurrentFolderId] = useState<string | null>(null); const lazyChildren: Record<string, FsNode[]> = { src: [ { id: "src/app", name: "app", type: "folder", parentId: "src" }, { id: "src/lib", name: "lib", type: "folder", parentId: "src" }, { id: "src/index.ts", name: "index.ts", type: "file", parentId: "src", }, ], "src/app": [ { id: "src/app/page.tsx", name: "page.tsx", type: "file", parentId: "src/app", }, ], "src/lib": [ { id: "src/lib/utils.ts", name: "utils.ts", type: "file", parentId: "src/lib", }, ], public: [ { id: "public/og.png", name: "og.png", type: "file", parentId: "public", }, ], }; return ( <Frame> <FileManager nodes={nodes} currentFolderId={currentFolderId} onCurrentFolderChange={({ folderId }) => setCurrentFolderId(folderId)} title="Lazy load" onLoadChildren={async ({ nodeId }) => { await new Promise((r) => setTimeout(r, 350)); const kids = lazyChildren[nodeId] ?? []; setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids)); return kids; }} /> </Frame> );} function VirtualizedDemo() { return ( <Frame> <FileManager nodes={dummyLargeFolder} defaultCurrentFolderId={null} defaultViewMode="list" title="250 items (virtualized)" showBackForward={false} showUpButton={false} showPathBar={false} /> </Frame> );} export default function FileManagerDemo() { return ( <Tabs defaultValue="standalone" className="w-full"> <SwipeTabsList> <TabsTrigger value="standalone">Standalone</TabsTrigger> <TabsTrigger value="flat">Flat grid</TabsTrigger> <TabsTrigger value="crud">Full CRUD + clipboard</TabsTrigger> <TabsTrigger value="lazy">Lazy load</TabsTrigger> <TabsTrigger value="virtual">Virtualized list</TabsTrigger> </SwipeTabsList> <TabsContent value="standalone"> <StandaloneGridDemo /> </TabsContent> <TabsContent value="flat"> <FlatGridDemo /> </TabsContent> <TabsContent value="crud"> <FullCrudDemo /> </TabsContent> <TabsContent value="lazy"> <LazyLoadDemo /> </TabsContent> <TabsContent value="virtual"> <VirtualizedDemo /> </TabsContent> </Tabs> );} Usage
When to use
FileManager is the Mac-Finder content pane: a grid / list view of the current folder with multi-select, cut / copy / paste, drag-and-drop, sort, and view-mode switching. Pair it with file-tree in the sidebar slot for the dual-pane Finder layout. Standalone use is also supported.
Data shape
Same FsNode shape as file-tree — id / name / type / parentId / children / ext / size / modifiedAt / icon / meta. Consumer owns nodes and currentFolderId; manager fires object-shape callbacks on every operation.
Shared clipboard
Wrap one or more <FileManager> instances in <FileClipboardProvider> to sync cut / copy / paste across instances. Without a provider, each manager keeps its own internal clipboard. Controlled mode: pass clipboard + onClipboardChange.
import { FileManager, FileClipboardProvider } from "@/components/file-manager"
<FileClipboardProvider>
<FileManager nodes={nodes} sidebar={<FileTree nodes={nodes} />} />
</FileClipboardProvider>Lazy loading
import { mergeLoadedChildren } from "@/components/file-manager"
<FileManager
nodes={nodes}
currentFolderId={current}
onCurrentFolderChange={({ folderId }) => setCurrent(folderId)}
onLoadChildren={async ({ nodeId }) => {
const kids = await fs.list(nodeId);
setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids));
return kids;
}}
/>Keyboard map
- Arrow keys move focus (2-D nav in grid mode, up/down only in list mode).
- Enter opens a file (
onOpen) or navigates into a folder. - Backspace deletes selected items (with confirm), or navigates up to parent if no selection.
- F2 renames; Delete deletes; Esc clears selection or cancels rename.
- Cmd/Ctrl+X / C / V cut / copy / paste; Cmd/Ctrl+A select all visible.
- Cmd/Ctrl+[ back; Cmd/Ctrl+] forward.
- Type-ahead: typing letters jumps focus to the first matching item name (resets after 800ms).
Drag-and-drop
- Within the manager: drag selected items onto a folder. Cycle / self-drop refused; drops on files are rejected (only folders are valid targets).
- From the desktop: drop OS files onto the manager to fire
onExternalDrop.targetFolderIdis the folder the user dropped on, or the current folder otherwise. - Marquee selection: drag a rectangle on empty space to select multiple items. Shift+drag adds to existing selection.
Custom chrome
Replace the toolbar via renderToolbar (typed context), or compose the standalone parts: FileManagerToolbar,FileManagerPathBar, FileManagerViewToggle,FileManagerIconSizeControl, FileManagerSortMenu, FileManagerSearchInput, FileManagerStatusBar. They read from useFileManager().
Gotchas
- Drops on files are rejected; only folders accept drops. Drops on empty whitespace are no-ops for internal drag, upload-to-current for external drag.
- Selection clears on navigate by default. Set
preserveSelectionOnNavigate={true}to keep it. - Cut / copy / paste require
onPastewired. Without a paste handler, the manager firesonClipboardChangebut paste does nothing. - Dragging files OUT to the desktop is not supported in v0.1.0. Add a Download button via
renderContextMenuor the toolbar overflow. - List-view virtualizes at
virtualizeThresholditems (default 200). Grid view does NOT virtualize at v0.1.0.
Features
- Grid + list view modes with three icon sizes (sm/md/lg) in grid mode
- Path bar / breadcrumbs with click-to-edit text input mode
- Back / Forward / Up navigation with built-in 50-entry history (controllable bypass)
- Multi-select with Cmd/Ctrl+click, Shift+click range, Cmd/Ctrl+A, plus marquee (drag-rectangle)
- Cut / copy / paste backed by a shared `<FileClipboardProvider>` primitive
- Right-click menu (Open / New / Cut / Copy / Paste / Rename / Delete / Refresh) with `renderContextMenu` slot
- Inline rename via F2 / double-click; optional `validateRename`
- Drag-and-drop within the manager (move) + drag-from-OS (`onExternalDrop`)
- Cycle / self-drop pre-validation; only folders are valid drop targets
- Lazy children loading via `onLoadChildren` + exported `mergeLoadedChildren` helper
- Built-in sort menu (Name / Modified / Size / Type, asc/desc) + sortable list-view headers
- Search input filtering current folder by name (case-insensitive substring)
- Type-ahead select (typing letters jumps focus to matching name)
- Status bar (item count / selected / total size); replaceable via `renderStatusBar`
- Sidebar + details slots for dual-pane / preview compositions
- List-view virtualization at >=200 items via TanStack Virtual
- Object-shape callbacks (F-cross-12-correct from day one)
- WCAG 2.1 AA — `role=grid`, `aria-multiselectable`, roving tabindex, live-region announcements