ilinxa
blockfile-managersource: ok · hit
File Manager
Finder-style file browser — grid and list views, marquee multi-select, cut copy paste, drag-and-drop, and a shared clipboard primitive.
npx shadcn add "https://ui.ilinxa.com/r/file-manager.json"free
Where it goes
kanban
Interaction
marqueedragdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
alert-dialogbuttoncontext-menudropdown-menuinputtooltip@ilinxa/file-clipboard
Files42 · source cached 10/14/2026, 7:49:35 AM
src/registry/components/navigation/file-manager/file-manager.tsx
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import type { MouseEvent } from "react";
import { cn } from "@/lib/utils";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
DEFAULT_FILE_MANAGER_LABELS,
type FileManagerActions,
type FileManagerHandle,
type FileManagerItem,
type FileManagerProps,
type FileManagerState,
type FileManagerToolbarContext,
type FsNode,
} from "./types";
import {
FileManagerContext,
type FileManagerContextValue,
} from "./hooks/use-file-manager-context";
import { useCurrentFolder } from "./hooks/use-current-folder";
import { useSelection } from "./hooks/use-selection";
import { useClipboard } from "./hooks/use-clipboard";
import { useSortSearch } from "./hooks/use-sort-search";
import { useVisibleItems } from "./hooks/use-visible-items";
import { useLazyLoad } from "./hooks/use-lazy-load";
import { useDrag } from "./hooks/use-drag";
import { useKeyboard } from "./hooks/use-keyboard";
import { useMarquee } from "./hooks/use-marquee";
import {
countAllNodes,
findNode,
indexNodes,
} from "./lib/tree-utils";
import { validateNodes } from "./lib/validation";
import { FileManagerToolbar } from "./parts/file-manager-toolbar";
import { FileManagerContentPane } from "./parts/file-manager-content-pane";
import { FileManagerStatusBar } from "./parts/file-manager-status-bar";
import { FileManagerDragOverlay } from "./parts/file-manager-drag-overlay";
import { FileManagerDeleteConfirm } from "./parts/file-manager-delete-confirm";
import { FileManagerContextMenu } from "./parts/file-manager-context-menu";
const LIST_ROW_HEIGHT = 32;
export const FileManager = forwardRef<
FileManagerHandle,
FileManagerProps
>(function FileManager(props, ref) {
const {
nodes,
currentFolderId: controlledFolderId,
defaultCurrentFolderId,
onCurrentFolderChange,
onLoadChildren,
selectedIds,
defaultSelectedIds,
onSelectedChange,
preserveSelectionOnNavigate = false,
clipboard,
defaultClipboard,
onClipboardChange,
onOpen,
onCreate,
onRename,
onDelete,
onMove,
onPaste,
onRefresh,
onExternalDrop,
validateRename,
viewMode,
defaultViewMode,
onViewModeChange,
iconSize,
defaultIconSize,
onIconSizeChange,
sort,
defaultSort,
onSortChange,
sortItems,
searchQuery,
defaultSearchQuery,
onSearchQueryChange,
filterItems,
showHidden = false,
isHidden,
enableHistory = true,
historyBackIds,
historyForwardIds,
onPathTyped,
iconForNode,
renderItem,
renderToolbar,
renderContextMenu,
renderEmpty,
renderLoading,
renderStatusBar,
renderDeleteConfirm,
sidebar,
details,
toolbar = true,
title,
showNewFile = true,
showNewFolder = true,
showRefresh = true,
showSearch = true,
showViewToggle = true,
showIconSize = true,
showSort = true,
showBackForward = true,
showUpButton = true,
showPathBar = true,
showStatusBar = true,
contextMenu = true,
contextMenuActions,
confirmDelete = true,
enableInternalDrag = true,
enableExternalDrop = true,
enableMarqueeSelection = true,
virtualize = "auto",
virtualizeThreshold = 200,
className,
style,
labels: labelsOverride,
} = props;
// Merge labels
const labels = useMemo(
() => ({ ...DEFAULT_FILE_MANAGER_LABELS, ...labelsOverride }),
[labelsOverride],
);
// Index nodes once per `nodes` change
const nodeIndex = useMemo(() => indexNodes(nodes), [nodes]);
// Validate nodes (dev only)
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
const warnings = validateNodes(nodes);
for (const w of warnings) {
console.warn("[file-manager]", w);
}
}, [nodes]);
// Pending delete state
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([]);
// Sort + search + view mode + icon size
const sortSearch = useSortSearch({
controlledSort: sort,
defaultSort,
onSortChange,
controlledSearch: searchQuery,
defaultSearch: defaultSearchQuery,
onSearchChange: onSearchQueryChange,
controlledViewMode: viewMode,
defaultViewMode,
onViewModeChange,
controlledIconSize: iconSize,
defaultIconSize,
onIconSizeChange,
});
// Current folder + history
const folder = useCurrentFolder({
nodes,
index: nodeIndex,
controlledFolderId,
defaultFolderId: defaultCurrentFolderId,
onCurrentFolderChange,
enableHistory,
controlledBack: historyBackIds,
controlledForward: historyForwardIds,
});
// Clipboard
const clipboardHook = useClipboard({
controlledClipboard: clipboard,
defaultClipboard,
onClipboardChange,
});
// Selection
const [focusedId, setFocusedId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const selection = useSelection({
controlledIds: selectedIds,
defaultIds: defaultSelectedIds,
onSelectedChange,
index: nodeIndex,
});
// Lazy load
const lazy = useLazyLoad({
nodes,
currentFolderId: folder.currentFolderId,
index: nodeIndex,
onLoadChildren,
});
// Visible items (filter + sort + flatten)
const { items, totalSize, visibleCount } = useVisibleItems({
nodes,
index: nodeIndex,
currentFolderId: folder.currentFolderId,
selectedIds: selection.selectedIds,
focusedId,
clipboard: clipboardHook.clipboard,
sort: sortSearch.sort,
searchQuery: sortSearch.searchQuery,
showHidden,
isHidden,
filterItems,
sortItems,
});
// Drag
const drag = useDrag({
nodes,
currentFolderId: folder.currentFolderId,
enableInternalDrag,
enableExternalDrop,
selectedIds: selection.selectedIds,
onMove,
onExternalDrop,
selectIds: (idOrIds) => selection.select(idOrIds),
});
// Refs
const rootElRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const columnsRef = useRef<number>(1);
// ── Roving tabindex → real DOM focus (v0.1.1, review 5.6) ─────────────
// The roving tabindex was state-only: arrow keys updated `focusedId` but
// never moved DOM focus — invisible to screen readers, dead focus ring.
// Mirrors app-sidebar's pattern; only steals focus while the user is
// already interacting inside the component.
useEffect(() => {
if (!focusedId) return;
if (typeof document === "undefined") return;
const root = rootElRef.current;
if (!root) return;
if (!root.contains(document.activeElement)) return;
const escaped = window.CSS.escape(focusedId);
const target = root.querySelector<HTMLElement>(
`[data-item-id="${escaped}"]`,
);
if (target) {
if (document.activeElement !== target) target.focus();
} else {
// Virtualized list mode: the focused row may not be mounted — fall
// back to the container so keyboard navigation keeps working.
root.focus({ preventScroll: true });
}
}, [focusedId]);
// Marquee selection
const marquee = useMarquee({
enabled: enableMarqueeSelection,
applySelection: (ids) => {
selection.select(ids);
},
snapshotSelection: () => new Set(selection.selectedIds),
containerRef: scrollRef,
});
// ── Actions ────────────────────────────────────────────────────────────
const triggerDelete = useCallback(
(ids: string[]) => {
if (ids.length === 0) return;
if (confirmDelete) {
setPendingDeleteIds(ids);
return;
}
onDelete?.({ ids });
},
[confirmDelete, onDelete],
);
const triggerOpen = useCallback(
(id: string) => {
const node = findNode(id, nodes);
if (!node) return;
if (node.type === "folder") {
if (!preserveSelectionOnNavigate) selection.clearSelection();
folder.navigateTo(id);
} else {
onOpen?.({ node });
}
},
[nodes, folder, onOpen, preserveSelectionOnNavigate, selection],
);
const paste = useCallback(() => {
const cb = clipboardHook.clipboard;
if (!cb.kind || cb.ids.length === 0) return;
onPaste?.({
ids: cb.ids,
kind: cb.kind,
targetFolderId: folder.currentFolderId,
});
if (cb.kind === "cut") {
clipboardHook.clearClipboard();
}
}, [clipboardHook, onPaste, folder.currentFolderId]);
// Wrap navigation to clear selection on navigate (unless preserved).
const maybeClearSelection = useCallback(() => {
if (!preserveSelectionOnNavigate) selection.clearSelection();
}, [preserveSelectionOnNavigate, selection]);
const navigateTo = useCallback(
(id: string | null) => {
maybeClearSelection();
folder.navigateTo(id);
},
[maybeClearSelection, folder],
);
const navigateUp = useCallback(() => {
maybeClearSelection();
folder.navigateUp();
}, [maybeClearSelection, folder]);
const navigateBack = useCallback(() => {
maybeClearSelection();
folder.navigateBack();
}, [maybeClearSelection, folder]);
const navigateForward = useCallback(() => {
maybeClearSelection();
folder.navigateForward();
}, [maybeClearSelection, folder]);
const actions: FileManagerActions = useMemo(
() => ({
navigateTo,
navigateUp,
navigateBack,
navigateForward,
refresh: (nodeId = null) => onRefresh?.({ nodeId }),
setViewMode: sortSearch.setViewMode,
setIconSize: sortSearch.setIconSize,
setSort: sortSearch.setSort,
setSearchQuery: sortSearch.setSearchQuery,
select: selection.select,
toggleSelection: selection.toggleSelection,
clearSelection: selection.clearSelection,
selectAll: () =>
selection.selectAll(items.map((i) => i.node.id)),
focusNode: (id) => setFocusedId(id),
startRename: (id) => setRenamingId(id),
cancelRename: () => setRenamingId(null),
triggerCreate: (type) =>
onCreate?.({ parentId: folder.currentFolderId, type }),
triggerDelete,
triggerOpen,
cut: clipboardHook.cut,
copy: clipboardHook.copy,
paste,
clearClipboard: clipboardHook.clearClipboard,
}),
[
navigateTo,
navigateUp,
navigateBack,
navigateForward,
folder.currentFolderId,
sortSearch,
selection,
items,
onRefresh,
onCreate,
triggerDelete,
triggerOpen,
clipboardHook,
paste,
],
);
// ── State ──────────────────────────────────────────────────────────────
const state: FileManagerState = useMemo(
() => ({
currentFolderId: folder.currentFolderId,
selectedIds: selection.selectedIds,
focusedId,
renamingId,
viewMode: sortSearch.viewMode,
iconSize: sortSearch.iconSize,
sort: sortSearch.sort,
searchQuery: sortSearch.searchQuery,
path: folder.path,
loadingChildren: lazy.loadingChildren,
loadError: lazy.loadError,
clipboard: clipboardHook.clipboard,
historyBackIds: folder.historyBackIds,
historyForwardIds: folder.historyForwardIds,
}),
[
folder.currentFolderId,
folder.path,
folder.historyBackIds,
folder.historyForwardIds,
selection.selectedIds,
focusedId,
renamingId,
sortSearch.viewMode,
sortSearch.iconSize,
sortSearch.sort,
sortSearch.searchQuery,
lazy.loadingChildren,
lazy.loadError,
clipboardHook.clipboard,
],
);
// Imperative handle
useImperativeHandle(
ref,
() => ({ state, actions }),
[state, actions],
);
// Keyboard
const kb = useKeyboard({
items,
focusedId,
renamingId,
selectedIds: selection.selectedIds,
viewMode: sortSearch.viewMode,
columnsRef,
actions,
onOpen,
});
// ── Item callbacks (click / dblclick / contextmenu) ───────────────────
const onItemClick = useCallback(
(e: MouseEvent, item: FileManagerItem) => {
if (e.metaKey || e.ctrlKey) {
selection.toggleSelection(item.node.id);
selection.setSelectionAnchorId(item.node.id);
} else if (e.shiftKey) {
const anchor = selection.getSelectionAnchorId();
if (!anchor) {
selection.select(item.node.id);
selection.setSelectionAnchorId(item.node.id);
} else {
const aIdx = items.findIndex((i) => i.node.id === anchor);
const bIdx = items.findIndex((i) => i.node.id === item.node.id);
if (aIdx !== -1 && bIdx !== -1) {
const [from, to] = aIdx < bIdx ? [aIdx, bIdx] : [bIdx, aIdx];
const ids = items.slice(from, to + 1).map((i) => i.node.id);
selection.select(ids);
}
}
} else {
selection.select(item.node.id);
selection.setSelectionAnchorId(item.node.id);
}
setFocusedId(item.node.id);
},
[items, selection],
);
const onItemDoubleClick = useCallback(
(_e: MouseEvent, item: FileManagerItem) => {
if (item.node.type === "folder") {
folder.navigateTo(item.node.id);
} else {
onOpen?.({ node: item.node });
}
},
[folder, onOpen],
);
const onItemContextMenu = useCallback(
(_e: MouseEvent, item: FileManagerItem) => {
if (!selection.selectedIds.has(item.node.id)) {
selection.select(item.node.id);
}
setFocusedId(item.node.id);
},
[selection],
);
const onRenameCommit = useCallback(
(id: string, nextName: string) => {
const node = findNode(id, nodes);
if (node && onRename) onRename({ id, node, nextName });
setRenamingId(null);
},
[nodes, onRename],
);
const onRenameCancel = useCallback(() => setRenamingId(null), []);
// ── Header / toolbar context ──────────────────────────────────────────
const headerCtx: FileManagerToolbarContext = useMemo(
() => ({
state,
actions,
showNewFile,
showNewFolder,
showRefresh,
showSearch,
showViewToggle,
showIconSize,
showSort,
showBackForward,
showUpButton,
showPathBar,
title,
labels,
}),
[
state,
actions,
showNewFile,
showNewFolder,
showRefresh,
showSearch,
showViewToggle,
showIconSize,
showSort,
showBackForward,
showUpButton,
showPathBar,
title,
labels,
],
);
// ── Pending delete dialog ─────────────────────────────────────────────
const pendingNodes: FsNode[] = useMemo(
() =>
pendingDeleteIds
.map((id) => nodeIndex.get(id))
.filter((n): n is FsNode => Boolean(n)),
[pendingDeleteIds, nodeIndex],
);
const onConfirmDelete = useCallback(() => {
if (pendingDeleteIds.length === 0) return;
onDelete?.({ ids: pendingDeleteIds });
setPendingDeleteIds([]);
}, [onDelete, pendingDeleteIds]);
const onCancelDelete = useCallback(() => {
setPendingDeleteIds([]);
}, []);
// ── Public Context value ──────────────────────────────────────────────
const totalCount = useMemo(() => countAllNodes(nodes), [nodes]);
const ctxValue: FileManagerContextValue = useMemo(
() => ({
state,
actions,
items,
totalCount,
visibleCount,
totalSize,
showNewFile,
showNewFolder,
showRefresh,
showSearch,
showViewToggle,
showIconSize,
showSort,
showBackForward,
showUpButton,
showPathBar,
showStatusBar,
title,
labels,
}),
[
state,
actions,
items,
totalCount,
visibleCount,
totalSize,
showNewFile,
showNewFolder,
showRefresh,
showSearch,
showViewToggle,
showIconSize,
showSort,
showBackForward,
showUpButton,
showPathBar,
showStatusBar,
title,
labels,
],
);
// ── Render ────────────────────────────────────────────────────────────
const isEmpty =
!lazy.loadingChildren && !lazy.loadError && items.length === 0;
const contextMenuGates = {
open: contextMenuActions?.open ?? true,
newFile: contextMenuActions?.newFile ?? true,
newFolder: contextMenuActions?.newFolder ?? true,
cut: contextMenuActions?.cut ?? true,
copy: contextMenuActions?.copy ?? true,
paste: contextMenuActions?.paste ?? true,
rename: contextMenuActions?.rename ?? true,
delete: contextMenuActions?.delete ?? true,
refresh: contextMenuActions?.refresh ?? true,
};
const wired = {
onOpen: !!onOpen,
onCreate: !!onCreate,
onRename: !!onRename,
onDelete: !!onDelete,
onMove: !!onMove,
onPaste: !!onPaste,
onRefresh: !!onRefresh,
};
return (
<FileManagerContext.Provider value={ctxValue}>
{/* No delayDuration — Radix-only prop; Base UI's TooltipProvider rejects it (F-cross-13). */}
<TooltipProvider>
<div
ref={rootElRef}
role="region"
aria-label={title ?? labels.title}
tabIndex={focusedId === null ? 0 : -1}
className={cn(
"relative flex h-full w-full overflow-hidden rounded-md border border-border/60 bg-background text-foreground outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
style={style}
onKeyDown={kb.onKeyDown}
>
{sidebar ? (
<aside className="flex w-64 shrink-0 flex-col border-r border-border/60 bg-card/30">
{sidebar}
</aside>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
{toolbar
? renderToolbar
? renderToolbar(headerCtx)
: <FileManagerToolbar onPathTyped={onPathTyped} />
: null}
<FileManagerContextMenu
state={state}
actions={actions}
enabled={contextMenu}
labels={labels}
gates={contextMenuGates}
wired={wired}
nodeIndex={nodeIndex}
renderContextMenu={renderContextMenu}
>
<FileManagerContentPane
items={items}
viewMode={sortSearch.viewMode}
iconSize={sortSearch.iconSize}
rowHeight={LIST_ROW_HEIGHT}
sort={sortSearch.sort}
onSetSort={sortSearch.setSort}
renamingId={renamingId}
validateRename={validateRename}
iconForNode={iconForNode}
enableInternalDrag={enableInternalDrag}
enableMarqueeSelection={enableMarqueeSelection}
dropTargetId={drag.dragOver?.itemId ?? null}
dropInvalid={drag.dragOver ? !drag.dragOver.valid : false}
loading={lazy.loadingChildren}
loadError={lazy.loadError}
onRetryLoad={lazy.retry}
isEmpty={isEmpty}
marqueeRect={marquee.rect}
marqueeActive={marquee.active}
onMarqueePointerDown={marquee.onPointerDown}
scrollRef={scrollRef}
columnsRef={columnsRef}
virtualize={virtualize}
virtualizeThreshold={virtualizeThreshold}
renderItem={renderItem}
renderEmpty={
renderEmpty
? () =>
renderEmpty({
actions,
showNewFile,
showNewFolder,
labels,
})
: undefined
}
renderLoading={renderLoading}
labels={labels}
onItemClick={onItemClick}
onItemDoubleClick={onItemDoubleClick}
onItemContextMenu={onItemContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onItemDragStart={drag.onItemDragStart}
onItemDragOver={drag.onItemDragOver}
onItemDragLeave={drag.onItemDragLeave}
onItemDrop={drag.onItemDrop}
onItemDragEnd={drag.onItemDragEnd}
onContainerDragEnter={drag.onContainerDragEnter}
onContainerDragOver={drag.onContainerDragOver}
onContainerDragLeave={drag.onContainerDragLeave}
onContainerDrop={drag.onContainerDrop}
/>
</FileManagerContextMenu>
{showStatusBar
? renderStatusBar
? renderStatusBar({
state,
actions,
totalCount,
selectedCount: selection.selectedIds.size,
totalSize,
labels,
})
: <FileManagerStatusBar />
: null}
</div>
{details ? (
<aside className="flex w-72 min-w-70 shrink-0 flex-col border-l border-border/60 bg-card/30">
{details}
</aside>
) : null}
<FileManagerDragOverlay
visible={drag.isExternalDragging}
labels={labels}
/>
{confirmDelete && pendingDeleteIds.length > 0 ? (
renderDeleteConfirm ? (
renderDeleteConfirm({
ids: pendingDeleteIds,
nodes: pendingNodes,
onConfirm: onConfirmDelete,
onCancel: onCancelDelete,
labels,
})
) : (
<FileManagerDeleteConfirm
open={true}
ids={pendingDeleteIds}
nodes={pendingNodes}
labels={labels}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
/>
)
) : null}
</div>
</TooltipProvider>
</FileManagerContext.Provider>
);
});
FileManager.displayName = "FileManager";
src/registry/components/navigation/file-manager/index.ts
export { FileManager } from "./file-manager";
export { FileManagerToolbar } from "./parts/file-manager-toolbar";
export { FileManagerPathBar } from "./parts/file-manager-path-bar";
export { FileManagerViewToggle } from "./parts/file-manager-view-toggle";
export { FileManagerIconSizeControl } from "./parts/file-manager-icon-size-control";
export { FileManagerSortMenu } from "./parts/file-manager-sort-menu";
export { FileManagerSearchInput } from "./parts/file-manager-search-input";
export { FileManagerStatusBar } from "./parts/file-manager-status-bar";
export { FileManagerBackForward } from "./parts/file-manager-back-forward";
export { FileManagerUpButton } from "./parts/file-manager-up-button";
export { FileManagerNewButtons } from "./parts/file-manager-new-buttons";
export { FileManagerRefreshButton } from "./parts/file-manager-refresh-button";
export { useFileManager } from "./hooks/use-file-manager-context";
export { mergeLoadedChildren } from "./lib/tree-utils";
export { iconForExtension } from "./lib/icons";
export { DEFAULT_FILE_MANAGER_LABELS } from "./types";
// Shared clipboard primitive (sibling _shared/ module). Re-exported for
// convenience so consumers don't need to import from a non-component path.
export {
FileClipboardProvider,
useFileClipboard,
EMPTY_CLIPBOARD,
} from "../_shared/file-clipboard";
export type {
FsNode,
FsNodeType,
FileManagerProps,
FileManagerHandle,
FileManagerState,
FileManagerActions,
FileManagerItem,
FileManagerViewMode,
FileManagerIconSize,
FileManagerSortKey,
FileManagerSortOrder,
FileManagerSortState,
FileManagerDropPosition,
FileManagerVirtualizeMode,
FileClipboard,
FileClipboardKind,
FileManagerOpenArgs,
FileManagerCurrentFolderChangeArgs,
FileManagerCreateArgs,
FileManagerRenameArgs,
FileManagerDeleteArgs,
FileManagerMoveArgs,
FileManagerPasteArgs,
FileManagerClipboardChangeArgs,
FileManagerRefreshArgs,
FileManagerExternalDropArgs,
FileManagerLoadChildrenArgs,
FileManagerSelectionChangeArgs,
FileManagerSortChangeArgs,
FileManagerViewModeChangeArgs,
FileManagerIconSizeChangeArgs,
FileManagerSearchChangeArgs,
FileManagerPathTypedArgs,
FileManagerValidateRenameArgs,
FileManagerIconForNodeArgs,
FileManagerContextMenuItem,
FileManagerContextMenuItemId,
FileManagerToolbarContext,
FileManagerStatusBarContext,
FileManagerContextMenuContext,
FileManagerEmptyContext,
FileManagerItemRenderArgs,
FileManagerDeleteConfirmContext,
FileManagerLabels,
} from "./types";
src/registry/components/navigation/file-manager/types.ts
import type { CSSProperties, ReactNode, Ref } from "react";
import type {
FileClipboard,
FileClipboardKind,
} from "../_shared/file-clipboard";
export type { FileClipboard, FileClipboardKind };
export type FsNodeType = "file" | "folder";
/** Hierarchical-node shape — compatible-by-convention with file-tree's FsNode. */
export interface FsNode {
id: string;
name: string;
type: FsNodeType;
parentId?: string | null;
children?: FsNode[];
ext?: string;
size?: number;
modifiedAt?: string;
icon?: ReactNode;
meta?: Record<string, unknown>;
}
export type FileManagerViewMode = "grid" | "list";
export type FileManagerIconSize = "sm" | "md" | "lg";
export type FileManagerSortKey = "name" | "modified" | "size" | "type";
export type FileManagerSortOrder = "asc" | "desc";
export type FileManagerDropPosition = "inside";
export type FileManagerVirtualizeMode = "auto" | "always" | "never";
export interface FileManagerSortState {
key: FileManagerSortKey;
order: FileManagerSortOrder;
}
/** Visible-item descriptor produced for each visible cell/row. */
export interface FileManagerItem {
node: FsNode;
selected: boolean;
focused: boolean;
cut: boolean;
index: number;
totalVisible: number;
}
/** Read-only state surfaced to slots and the imperative ref. */
export interface FileManagerState {
currentFolderId: string | null;
selectedIds: ReadonlySet<string>;
focusedId: string | null;
renamingId: string | null;
viewMode: FileManagerViewMode;
iconSize: FileManagerIconSize;
sort: FileManagerSortState;
searchQuery: string;
/** Path from root to the current folder (root → ... → current). Empty when at root. */
path: FsNode[];
loadingChildren: boolean;
loadError: string | null;
clipboard: FileClipboard;
historyBackIds: string[];
historyForwardIds: string[];
}
/** Imperative actions exposed to slots and the imperative ref. */
export interface FileManagerActions {
navigateTo: (folderId: string | null) => void;
navigateUp: () => void;
navigateBack: () => void;
navigateForward: () => void;
refresh: (nodeId?: string | null) => void;
setViewMode: (mode: FileManagerViewMode) => void;
setIconSize: (size: FileManagerIconSize) => void;
setSort: (sort: FileManagerSortState) => void;
setSearchQuery: (query: string) => void;
select: (idOrIds: string | string[]) => void;
toggleSelection: (id: string) => void;
clearSelection: () => void;
selectAll: () => void;
focusNode: (id: string) => void;
startRename: (id: string) => void;
cancelRename: () => void;
triggerCreate: (type: FsNodeType) => void;
triggerDelete: (ids: string[]) => void;
triggerOpen: (id: string) => void;
cut: (ids: string[]) => void;
copy: (ids: string[]) => void;
paste: () => void;
clearClipboard: () => void;
}
export interface FileManagerHandle {
state: FileManagerState;
actions: FileManagerActions;
}
// ─── Callback arg shapes (object-shape per F-cross-12) ─────────────────────
export interface FileManagerOpenArgs {
node: FsNode;
}
export interface FileManagerCurrentFolderChangeArgs {
folderId: string | null;
}
export interface FileManagerCreateArgs {
parentId: string | null;
type: FsNodeType;
}
export interface FileManagerRenameArgs {
id: string;
node: FsNode;
nextName: string;
}
export interface FileManagerDeleteArgs {
ids: string[];
}
export interface FileManagerMoveArgs {
ids: string[];
targetId: string | null;
position: FileManagerDropPosition;
}
export interface FileManagerPasteArgs {
ids: string[];
kind: FileClipboardKind;
targetFolderId: string | null;
}
export interface FileManagerClipboardChangeArgs {
clipboard: FileClipboard;
}
export interface FileManagerRefreshArgs {
nodeId: string | null;
}
export interface FileManagerExternalDropArgs {
files: File[];
targetFolderId: string | null;
}
export interface FileManagerLoadChildrenArgs {
nodeId: string;
node: FsNode;
}
export interface FileManagerSelectionChangeArgs {
ids: Set<string>;
}
export interface FileManagerSortChangeArgs {
sort: FileManagerSortState;
}
export interface FileManagerViewModeChangeArgs {
mode: FileManagerViewMode;
}
export interface FileManagerIconSizeChangeArgs {
size: FileManagerIconSize;
}
export interface FileManagerSearchChangeArgs {
query: string;
}
export interface FileManagerPathTypedArgs {
path: string;
}
export interface FileManagerValidateRenameArgs {
node: FsNode;
nextName: string;
}
export interface FileManagerIconForNodeArgs {
node: FsNode;
}
// ─── Default labels ─────────────────────────────────────────────────────────
export interface FileManagerLabels {
title: string;
back: string;
forward: string;
up: string;
refresh: string;
newFile: string;
newFolder: string;
searchPlaceholder: string;
viewGrid: string;
viewList: string;
iconSizeSmall: string;
iconSizeMedium: string;
iconSizeLarge: string;
sortByName: string;
sortByModified: string;
sortBySize: string;
sortByType: string;
sortAsc: string;
sortDesc: string;
contextOpen: string;
contextNewFile: string;
contextNewFolder: string;
contextCut: string;
contextCopy: string;
contextPaste: string;
contextRename: string;
contextDelete: string;
contextRefresh: string;
deleteConfirmTitle: string;
deleteConfirmDescription: string;
deleteConfirmAction: string;
deleteConfirmCancel: string;
emptyTitle: string;
loading: string;
loadError: string;
retry: string;
externalDropOverlay: string;
itemCount: string;
itemCountSelected: string;
totalSize: string;
pathRoot: string;
columnName: string;
columnModified: string;
columnSize: string;
columnType: string;
}
// ─── Slot contexts ──────────────────────────────────────────────────────────
export interface FileManagerToolbarContext {
state: FileManagerState;
actions: FileManagerActions;
showNewFile: boolean;
showNewFolder: boolean;
showRefresh: boolean;
showSearch: boolean;
showViewToggle: boolean;
showIconSize: boolean;
showSort: boolean;
showBackForward: boolean;
showUpButton: boolean;
showPathBar: boolean;
title?: string;
labels: FileManagerLabels;
}
export interface FileManagerStatusBarContext {
state: FileManagerState;
actions: FileManagerActions;
totalCount: number;
selectedCount: number;
totalSize: number;
labels: FileManagerLabels;
}
export type FileManagerContextMenuItemId =
| "open"
| "new-file"
| "new-folder"
| "cut"
| "copy"
| "paste"
| "rename"
| "delete"
| "refresh";
export interface FileManagerContextMenuItem {
id: FileManagerContextMenuItemId;
label: string;
onSelect: () => void;
disabled?: boolean;
destructive?: boolean;
icon?: ReactNode;
}
export interface FileManagerContextMenuContext {
state: FileManagerState;
actions: FileManagerActions;
/** Right-clicked item — `null` when right-clicking empty whitespace. */
node: FsNode | null;
defaultActions: FileManagerContextMenuItem[];
position: { x: number; y: number };
labels: FileManagerLabels;
}
export interface FileManagerEmptyContext {
actions: FileManagerActions;
showNewFile: boolean;
showNewFolder: boolean;
labels: FileManagerLabels;
}
export interface FileManagerItemRenderArgs {
item: FileManagerItem;
defaultItem: ReactNode;
viewMode: FileManagerViewMode;
}
export interface FileManagerDeleteConfirmContext {
ids: string[];
nodes: FsNode[];
onConfirm: () => void;
onCancel: () => void;
labels: FileManagerLabels;
}
// ─── Top-level props ────────────────────────────────────────────────────────
export interface FileManagerProps {
// Data
nodes: FsNode[];
// Current folder
currentFolderId?: string | null;
defaultCurrentFolderId?: string | null;
onCurrentFolderChange?: (
args: FileManagerCurrentFolderChangeArgs,
) => void;
// Lazy load
onLoadChildren?: (
args: FileManagerLoadChildrenArgs,
) => Promise<FsNode[]>;
// Selection
selectedIds?: Set<string>;
defaultSelectedIds?: Set<string>;
onSelectedChange?: (args: FileManagerSelectionChangeArgs) => void;
preserveSelectionOnNavigate?: boolean;
// Clipboard
clipboard?: FileClipboard;
defaultClipboard?: FileClipboard;
onClipboardChange?: (args: FileManagerClipboardChangeArgs) => void;
// Operations
onOpen?: (args: FileManagerOpenArgs) => void;
onCreate?: (args: FileManagerCreateArgs) => void;
onRename?: (args: FileManagerRenameArgs) => void;
onDelete?: (args: FileManagerDeleteArgs) => void;
onMove?: (args: FileManagerMoveArgs) => void;
onPaste?: (args: FileManagerPasteArgs) => void;
onRefresh?: (args: FileManagerRefreshArgs) => void;
onExternalDrop?: (args: FileManagerExternalDropArgs) => void;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
// View mode
viewMode?: FileManagerViewMode;
defaultViewMode?: FileManagerViewMode;
onViewModeChange?: (args: FileManagerViewModeChangeArgs) => void;
// Icon size
iconSize?: FileManagerIconSize;
defaultIconSize?: FileManagerIconSize;
onIconSizeChange?: (args: FileManagerIconSizeChangeArgs) => void;
// Sort
sort?: FileManagerSortState;
defaultSort?: FileManagerSortState;
onSortChange?: (args: FileManagerSortChangeArgs) => void;
sortItems?: (a: FsNode, b: FsNode, sort: FileManagerSortState) => number;
// Search / filter
searchQuery?: string;
defaultSearchQuery?: string;
onSearchQueryChange?: (args: FileManagerSearchChangeArgs) => void;
filterItems?: (node: FsNode, query: string) => boolean;
showHidden?: boolean;
isHidden?: (node: FsNode) => boolean;
// Navigation history
enableHistory?: boolean;
historyBackIds?: string[];
historyForwardIds?: string[];
onPathTyped?: (args: FileManagerPathTypedArgs) => void;
// Rendering
iconForNode?: (args: FileManagerIconForNodeArgs) => ReactNode;
renderItem?: (args: FileManagerItemRenderArgs) => ReactNode;
renderToolbar?: (ctx: FileManagerToolbarContext) => ReactNode;
renderContextMenu?: (ctx: FileManagerContextMenuContext) => ReactNode;
renderEmpty?: (ctx: FileManagerEmptyContext) => ReactNode;
renderLoading?: () => ReactNode;
renderStatusBar?: (ctx: FileManagerStatusBarContext) => ReactNode;
renderDeleteConfirm?: (ctx: FileManagerDeleteConfirmContext) => ReactNode;
// Slots
sidebar?: ReactNode;
details?: ReactNode;
// Toolbar config
toolbar?: boolean;
title?: string;
showNewFile?: boolean;
showNewFolder?: boolean;
showRefresh?: boolean;
showSearch?: boolean;
showViewToggle?: boolean;
showIconSize?: boolean;
showSort?: boolean;
showBackForward?: boolean;
showUpButton?: boolean;
showPathBar?: boolean;
// Status bar
showStatusBar?: boolean;
// Context-menu config
contextMenu?: boolean;
contextMenuActions?: {
open?: boolean;
newFile?: boolean;
newFolder?: boolean;
cut?: boolean;
copy?: boolean;
paste?: boolean;
rename?: boolean;
delete?: boolean;
refresh?: boolean;
};
// Confirmation
confirmDelete?: boolean;
// DnD
enableInternalDrag?: boolean;
enableExternalDrop?: boolean;
enableMarqueeSelection?: boolean;
// Virtualization (list view only at v0.1.0)
virtualize?: FileManagerVirtualizeMode;
virtualizeThreshold?: number;
// Polymorphic
className?: string;
style?: CSSProperties;
// Imperative handle
ref?: Ref<FileManagerHandle>;
// i18n labels
labels?: Partial<FileManagerLabels>;
}
export const DEFAULT_FILE_MANAGER_LABELS: FileManagerLabels = {
title: "Files",
back: "Back",
forward: "Forward",
up: "Up",
refresh: "Refresh",
newFile: "New File",
newFolder: "New Folder",
searchPlaceholder: "Search...",
viewGrid: "Grid view",
viewList: "List view",
iconSizeSmall: "Small icons",
iconSizeMedium: "Medium icons",
iconSizeLarge: "Large icons",
sortByName: "Name",
sortByModified: "Date Modified",
sortBySize: "Size",
sortByType: "Type",
sortAsc: "Ascending",
sortDesc: "Descending",
contextOpen: "Open",
contextNewFile: "New File",
contextNewFolder: "New Folder",
contextCut: "Cut",
contextCopy: "Copy",
contextPaste: "Paste",
contextRename: "Rename",
contextDelete: "Delete",
contextRefresh: "Refresh",
deleteConfirmTitle: "Delete items?",
deleteConfirmDescription: "This action cannot be undone.",
deleteConfirmAction: "Delete",
deleteConfirmCancel: "Cancel",
emptyTitle: "This folder is empty",
loading: "Loading…",
loadError: "Failed to load",
retry: "Retry",
externalDropOverlay: "Drop files here",
itemCount: "items",
itemCountSelected: "selected",
totalSize: "total",
pathRoot: "Files",
columnName: "Name",
columnModified: "Modified",
columnSize: "Size",
columnType: "Type",
};
src/registry/components/navigation/file-manager/hooks/use-file-manager-context.ts
"use client";
import { createContext, useContext } from "react";
import type {
FileManagerActions,
FileManagerItem,
FileManagerLabels,
FileManagerState,
} from "../types";
export interface FileManagerContextValue {
state: FileManagerState;
actions: FileManagerActions;
/** Visible items in the current folder, post-sort + filter + hidden. */
items: FileManagerItem[];
/** Total node count across the entire `nodes` tree (recursive). */
totalCount: number;
/** Visible-item count in the current folder. */
visibleCount: number;
/** Sum of `size` over visible items (folders contribute 0). */
totalSize: number;
/** Header config flags (read by toolbar atoms). */
showNewFile: boolean;
showNewFolder: boolean;
showRefresh: boolean;
showSearch: boolean;
showViewToggle: boolean;
showIconSize: boolean;
showSort: boolean;
showBackForward: boolean;
showUpButton: boolean;
showPathBar: boolean;
showStatusBar: boolean;
title?: string;
labels: FileManagerLabels;
}
export const FileManagerContext =
createContext<FileManagerContextValue | null>(null);
/**
* Public consumer hook for advanced custom-chrome composition. Used by the
* standalone toolbar atoms internally and exposed to consumers writing a
* `renderToolbar` slot.
*
* Throws if called outside `<FileManager>`.
*/
export function useFileManager(): FileManagerContextValue {
const ctx = useContext(FileManagerContext);
if (!ctx) {
throw new Error(
"useFileManager() must be called inside <FileManager>",
);
}
return ctx;
}
src/registry/components/navigation/file-manager/hooks/use-current-folder.ts
"use client";
import { useCallback, useMemo, useState } from "react";
import type {
FileManagerCurrentFolderChangeArgs,
FsNode,
} from "../types";
import { buildPath } from "../lib/path";
import { useNavigationHistory } from "./use-navigation-history";
interface UseCurrentFolderArgs {
nodes: FsNode[];
index: Map<string, FsNode>;
controlledFolderId?: string | null;
defaultFolderId?: string | null;
onCurrentFolderChange?: (
args: FileManagerCurrentFolderChangeArgs,
) => void;
enableHistory: boolean;
controlledBack?: string[];
controlledForward?: string[];
}
export interface UseCurrentFolderResult {
currentFolderId: string | null;
path: FsNode[];
navigateTo: (folderId: string | null) => void;
navigateUp: () => void;
navigateBack: () => void;
navigateForward: () => void;
historyBackIds: string[];
historyForwardIds: string[];
}
export function useCurrentFolder(
args: UseCurrentFolderArgs,
): UseCurrentFolderResult {
const {
index,
controlledFolderId,
defaultFolderId,
onCurrentFolderChange,
enableHistory,
controlledBack,
controlledForward,
} = args;
const [internalFolderId, setInternalFolderId] = useState<string | null>(
defaultFolderId ?? null,
);
const currentFolderId =
controlledFolderId !== undefined ? controlledFolderId : internalFolderId;
const history = useNavigationHistory({
enableHistory,
controlledBack,
controlledForward,
});
const path = useMemo(
() => buildPath(currentFolderId, index),
[currentFolderId, index],
);
const setFolder = useCallback(
(next: string | null, pushToHistory: boolean) => {
if (next === currentFolderId) return;
if (pushToHistory) {
history.pushVisit(currentFolderId);
}
if (controlledFolderId === undefined) setInternalFolderId(next);
onCurrentFolderChange?.({ folderId: next });
},
[currentFolderId, controlledFolderId, onCurrentFolderChange, history],
);
const navigateTo = useCallback(
(folderId: string | null) => {
setFolder(folderId, true);
},
[setFolder],
);
const navigateUp = useCallback(() => {
if (currentFolderId === null) return;
const current = index.get(currentFolderId);
const parentId = current?.parentId ?? null;
setFolder(parentId, true);
}, [currentFolderId, index, setFolder]);
const navigateBack = useCallback(() => {
const previous = history.popBack(currentFolderId);
if (previous === undefined) return;
if (controlledFolderId === undefined) setInternalFolderId(previous);
onCurrentFolderChange?.({ folderId: previous });
}, [history, currentFolderId, controlledFolderId, onCurrentFolderChange]);
const navigateForward = useCallback(() => {
const next = history.popForward(currentFolderId);
if (next === undefined) return;
if (controlledFolderId === undefined) setInternalFolderId(next);
onCurrentFolderChange?.({ folderId: next });
}, [history, currentFolderId, controlledFolderId, onCurrentFolderChange]);
return {
currentFolderId,
path,
navigateTo,
navigateUp,
navigateBack,
navigateForward,
historyBackIds: history.backIds,
historyForwardIds: history.forwardIds,
};
}
src/registry/components/navigation/file-manager/hooks/use-navigation-history.ts
"use client";
import { useCallback, useState } from "react";
const MAX_HISTORY = 50;
interface UseNavigationHistoryArgs {
enableHistory: boolean;
controlledBack?: string[];
controlledForward?: string[];
}
export interface UseNavigationHistoryResult {
backIds: string[];
forwardIds: string[];
/** Push the *previous* folder onto the back stack when a fresh navigation happens. */
pushVisit: (previousFolderId: string | null) => void;
/** Pop the back stack; returns the destination id (or `undefined` if empty). */
popBack: (currentFolderId: string | null) => string | null | undefined;
/** Pop the forward stack; returns the destination id (or `undefined` if empty). */
popForward: (currentFolderId: string | null) => string | null | undefined;
}
const ROOT_SENTINEL = "__root__";
const encode = (id: string | null): string =>
id === null ? ROOT_SENTINEL : id;
const decode = (s: string | undefined): string | null | undefined =>
s === undefined ? undefined : s === ROOT_SENTINEL ? null : s;
export function useNavigationHistory(
args: UseNavigationHistoryArgs,
): UseNavigationHistoryResult {
const { enableHistory, controlledBack, controlledForward } = args;
const [internalBack, setInternalBack] = useState<string[]>([]);
const [internalForward, setInternalForward] = useState<string[]>([]);
const backIds =
controlledBack ?? (enableHistory ? internalBack : []);
const forwardIds =
controlledForward ?? (enableHistory ? internalForward : []);
const pushVisit = useCallback(
(previousFolderId: string | null) => {
if (!enableHistory) return;
if (controlledBack) return;
setInternalBack((prev) => {
const next = [...prev, encode(previousFolderId)];
if (next.length > MAX_HISTORY)
next.splice(0, next.length - MAX_HISTORY);
return next;
});
setInternalForward([]);
},
[enableHistory, controlledBack],
);
const popBack = useCallback(
(currentFolderId: string | null): string | null | undefined => {
if (!enableHistory) return undefined;
if (controlledBack) return undefined;
if (internalBack.length === 0) return undefined;
const previous = decode(internalBack[internalBack.length - 1]);
setInternalBack(internalBack.slice(0, -1));
setInternalForward([...internalForward, encode(currentFolderId)]);
return previous;
},
[enableHistory, controlledBack, internalBack, internalForward],
);
const popForward = useCallback(
(currentFolderId: string | null): string | null | undefined => {
if (!enableHistory) return undefined;
if (controlledForward) return undefined;
if (internalForward.length === 0) return undefined;
const next = decode(internalForward[internalForward.length - 1]);
setInternalForward(internalForward.slice(0, -1));
setInternalBack([...internalBack, encode(currentFolderId)]);
return next;
},
[enableHistory, controlledForward, internalBack, internalForward],
);
return { backIds, forwardIds, pushVisit, popBack, popForward };
}
src/registry/components/navigation/file-manager/hooks/use-selection.ts
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import type {
FileManagerSelectionChangeArgs,
FsNode,
} from "../types";
interface UseSelectionArgs {
controlledIds?: Set<string>;
defaultIds?: Set<string>;
onSelectedChange?: (args: FileManagerSelectionChangeArgs) => void;
/** Used to drop ids that no longer reference live nodes. */
index: Map<string, FsNode>;
}
export interface UseSelectionResult {
selectedIds: ReadonlySet<string>;
select: (idOrIds: string | string[]) => void;
toggleSelection: (id: string) => void;
clearSelection: () => void;
selectAll: (visibleIds: string[]) => void;
/** For Shift+click range select. Read in event handler context only. */
getSelectionAnchorId: () => string | null;
setSelectionAnchorId: (id: string | null) => void;
}
export function useSelection(args: UseSelectionArgs): UseSelectionResult {
const { controlledIds, defaultIds, onSelectedChange, index } = args;
const [internalIds, setInternalIds] = useState<Set<string>>(
() => defaultIds ?? new Set(),
);
const anchorRef = useRef<string | null>(null);
const selectedIds = controlledIds ?? internalIds;
const setIds = useCallback(
(next: Set<string>) => {
if (controlledIds === undefined) setInternalIds(next);
onSelectedChange?.({ ids: next });
},
[controlledIds, onSelectedChange],
);
const select = useCallback(
(idOrIds: string | string[]) => {
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
setIds(new Set(ids));
if (ids.length > 0) anchorRef.current = ids[0];
},
[setIds],
);
const toggleSelection = useCallback(
(id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setIds(next);
anchorRef.current = id;
},
[selectedIds, setIds],
);
const clearSelection = useCallback(() => {
setIds(new Set());
anchorRef.current = null;
}, [setIds]);
const selectAll = useCallback(
(visibleIds: string[]) => {
setIds(new Set(visibleIds));
},
[setIds],
);
const getSelectionAnchorId = useCallback(() => anchorRef.current, []);
const setSelectionAnchorId = useCallback((id: string | null) => {
anchorRef.current = id;
}, []);
// Sanitize: drop ids that no longer reference real nodes
const sanitized = useMemo(() => {
if (selectedIds.size === 0) return selectedIds;
let dirty = false;
const next = new Set<string>();
for (const id of selectedIds) {
if (index.has(id)) next.add(id);
else dirty = true;
}
return dirty ? next : selectedIds;
}, [selectedIds, index]);
return {
selectedIds: sanitized,
select,
toggleSelection,
clearSelection,
selectAll,
getSelectionAnchorId,
setSelectionAnchorId,
};
}
src/registry/components/navigation/file-manager/hooks/use-clipboard.ts
"use client";
import { useCallback, useState } from "react";
import {
EMPTY_CLIPBOARD,
useFileClipboard,
type FileClipboard,
} from "../../_shared/file-clipboard";
import type { FileManagerClipboardChangeArgs } from "../types";
interface UseClipboardArgs {
/** Controlled clipboard (consumer manages state). */
controlledClipboard?: FileClipboard;
/** Initial value for uncontrolled mode. */
defaultClipboard?: FileClipboard;
onClipboardChange?: (args: FileManagerClipboardChangeArgs) => void;
}
export interface UseClipboardResult {
clipboard: FileClipboard;
cut: (ids: string[]) => void;
copy: (ids: string[]) => void;
clearClipboard: () => void;
setClipboard: (next: FileClipboard) => void;
}
/**
* Resolves clipboard state across three modes:
* 1. Controlled (`controlledClipboard` prop given) — consumer drives.
* 2. Provider (`<FileClipboardProvider>` up the tree) — synced across
* instances inside the provider.
* 3. Internal — local `useState`.
*
* Mode is decided in priority order: controlled > provider > internal.
*/
export function useClipboard(args: UseClipboardArgs): UseClipboardResult {
const { controlledClipboard, defaultClipboard, onClipboardChange } = args;
const provider = useFileClipboard();
const hasProvider = provider.__hasProvider;
const [internalClipboard, setInternalClipboard] = useState<FileClipboard>(
() => defaultClipboard ?? EMPTY_CLIPBOARD,
);
// Resolve current clipboard
let clipboard: FileClipboard;
if (controlledClipboard) {
clipboard = controlledClipboard;
} else if (hasProvider) {
clipboard = provider.clipboard;
} else {
clipboard = internalClipboard;
}
const setClipboard = useCallback(
(next: FileClipboard) => {
if (!controlledClipboard) {
if (hasProvider) provider.setClipboard(next);
else setInternalClipboard(next);
}
onClipboardChange?.({ clipboard: next });
},
[controlledClipboard, hasProvider, provider, onClipboardChange],
);
const cut = useCallback(
(ids: string[]) => {
setClipboard({ kind: "cut", ids });
},
[setClipboard],
);
const copy = useCallback(
(ids: string[]) => {
setClipboard({ kind: "copy", ids });
},
[setClipboard],
);
const clearClipboard = useCallback(() => {
setClipboard(EMPTY_CLIPBOARD);
}, [setClipboard]);
return {
clipboard,
cut,
copy,
clearClipboard,
setClipboard,
};
}
src/registry/components/navigation/file-manager/hooks/use-sort-search.ts
"use client";
import { useCallback, useState } from "react";
import type {
FileManagerIconSize,
FileManagerIconSizeChangeArgs,
FileManagerSearchChangeArgs,
FileManagerSortChangeArgs,
FileManagerSortState,
FileManagerViewMode,
FileManagerViewModeChangeArgs,
} from "../types";
interface UseSortSearchArgs {
controlledSort?: FileManagerSortState;
defaultSort?: FileManagerSortState;
onSortChange?: (args: FileManagerSortChangeArgs) => void;
controlledSearch?: string;
defaultSearch?: string;
onSearchChange?: (args: FileManagerSearchChangeArgs) => void;
controlledViewMode?: FileManagerViewMode;
defaultViewMode?: FileManagerViewMode;
onViewModeChange?: (args: FileManagerViewModeChangeArgs) => void;
controlledIconSize?: FileManagerIconSize;
defaultIconSize?: FileManagerIconSize;
onIconSizeChange?: (args: FileManagerIconSizeChangeArgs) => void;
}
const DEFAULT_SORT: FileManagerSortState = { key: "name", order: "asc" };
export interface UseSortSearchResult {
sort: FileManagerSortState;
searchQuery: string;
viewMode: FileManagerViewMode;
iconSize: FileManagerIconSize;
setSort: (next: FileManagerSortState) => void;
setSearchQuery: (next: string) => void;
setViewMode: (next: FileManagerViewMode) => void;
setIconSize: (next: FileManagerIconSize) => void;
}
export function useSortSearch(args: UseSortSearchArgs): UseSortSearchResult {
const {
controlledSort,
defaultSort,
onSortChange,
controlledSearch,
defaultSearch,
onSearchChange,
controlledViewMode,
defaultViewMode,
onViewModeChange,
controlledIconSize,
defaultIconSize,
onIconSizeChange,
} = args;
const [internalSort, setInternalSort] = useState<FileManagerSortState>(
defaultSort ?? DEFAULT_SORT,
);
const [internalSearch, setInternalSearch] = useState<string>(
defaultSearch ?? "",
);
const [internalViewMode, setInternalViewMode] = useState<FileManagerViewMode>(
defaultViewMode ?? "grid",
);
const [internalIconSize, setInternalIconSize] = useState<FileManagerIconSize>(
defaultIconSize ?? "md",
);
const sort = controlledSort ?? internalSort;
const searchQuery = controlledSearch ?? internalSearch;
const viewMode = controlledViewMode ?? internalViewMode;
const iconSize = controlledIconSize ?? internalIconSize;
const setSort = useCallback(
(next: FileManagerSortState) => {
if (controlledSort === undefined) setInternalSort(next);
onSortChange?.({ sort: next });
},
[controlledSort, onSortChange],
);
const setSearchQuery = useCallback(
(next: string) => {
if (controlledSearch === undefined) setInternalSearch(next);
onSearchChange?.({ query: next });
},
[controlledSearch, onSearchChange],
);
const setViewMode = useCallback(
(next: FileManagerViewMode) => {
if (controlledViewMode === undefined) setInternalViewMode(next);
onViewModeChange?.({ mode: next });
},
[controlledViewMode, onViewModeChange],
);
const setIconSize = useCallback(
(next: FileManagerIconSize) => {
if (controlledIconSize === undefined) setInternalIconSize(next);
onIconSizeChange?.({ size: next });
},
[controlledIconSize, onIconSizeChange],
);
return {
sort,
searchQuery,
viewMode,
iconSize,
setSort,
setSearchQuery,
setViewMode,
setIconSize,
};
}
src/registry/components/navigation/file-manager/hooks/use-visible-items.ts
"use client";
import { useMemo } from "react";
import type {
FileClipboard,
FileManagerItem,
FileManagerSortState,
FsNode,
} from "../types";
import { defaultSort, getChildrenOf } from "../lib/tree-utils";
import { getNodeExtension } from "../lib/icons";
interface UseVisibleItemsArgs {
nodes: FsNode[];
index: Map<string, FsNode>;
currentFolderId: string | null;
selectedIds: ReadonlySet<string>;
focusedId: string | null;
clipboard: FileClipboard;
sort: FileManagerSortState;
searchQuery: string;
showHidden: boolean;
isHidden?: (node: FsNode) => boolean;
filterItems?: (node: FsNode, query: string) => boolean;
sortItems?: (a: FsNode, b: FsNode, sort: FileManagerSortState) => number;
}
const defaultIsHidden = (node: FsNode) => node.name.startsWith(".");
const defaultFilter = (node: FsNode, query: string) =>
node.name.toLowerCase().includes(query.toLowerCase());
function defaultSortBy(
a: FsNode,
b: FsNode,
sort: FileManagerSortState,
): number {
// folders always before files regardless of sort key (Mac Finder behavior)
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
let cmp = 0;
switch (sort.key) {
case "name":
cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
break;
case "modified": {
const ta = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
const tb = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
cmp = ta - tb;
break;
}
case "size":
cmp = (a.size ?? 0) - (b.size ?? 0);
break;
case "type": {
const ea = getNodeExtension(a);
const eb = getNodeExtension(b);
cmp = ea.localeCompare(eb, undefined, { sensitivity: "base" });
if (cmp === 0) {
cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
}
break;
}
}
return sort.order === "asc" ? cmp : -cmp;
}
export interface UseVisibleItemsResult {
items: FileManagerItem[];
/** Total size in bytes across visible items (folders contribute 0). */
totalSize: number;
/** Visible item count (post-filter, post-hidden). */
visibleCount: number;
}
export function useVisibleItems(
args: UseVisibleItemsArgs,
): UseVisibleItemsResult {
const {
nodes,
index,
currentFolderId,
selectedIds,
focusedId,
clipboard,
sort,
searchQuery,
showHidden,
isHidden,
filterItems,
sortItems,
} = args;
return useMemo(() => {
const children = getChildrenOf(currentFolderId, nodes, index) ?? [];
const hiddenFn = isHidden ?? defaultIsHidden;
const filterFn = filterItems ?? defaultFilter;
const sorter = sortItems
? (a: FsNode, b: FsNode) => sortItems(a, b, sort)
: (a: FsNode, b: FsNode) => defaultSortBy(a, b, sort);
let filtered = showHidden
? [...children]
: children.filter((n) => !hiddenFn(n));
if (searchQuery.trim()) {
filtered = filtered.filter((n) => filterFn(n, searchQuery));
}
filtered.sort(sorter);
const cutSet = new Set(
clipboard.kind === "cut" ? clipboard.ids : [],
);
const total = filtered.length;
const totalSize = filtered.reduce(
(sum, node) => sum + (node.type === "file" ? (node.size ?? 0) : 0),
0,
);
const items: FileManagerItem[] = filtered.map((node, i) => ({
node,
selected: selectedIds.has(node.id),
focused: focusedId === node.id,
cut: cutSet.has(node.id),
index: i,
totalVisible: total,
}));
return { items, totalSize, visibleCount: total };
}, [
nodes,
index,
currentFolderId,
selectedIds,
focusedId,
clipboard,
sort,
searchQuery,
showHidden,
isHidden,
filterItems,
sortItems,
]);
}
// fallback so the unused-import lint doesn't fire when defaultSort is
// referenced by tree-utils consumers expecting it to be available
void defaultSort;
src/registry/components/navigation/file-manager/hooks/use-lazy-load.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FileManagerLoadChildrenArgs, FsNode } from "../types";
interface UseLazyLoadArgs {
nodes: FsNode[];
currentFolderId: string | null;
index: Map<string, FsNode>;
onLoadChildren?: (
args: FileManagerLoadChildrenArgs,
) => Promise<FsNode[]>;
}
export interface UseLazyLoadResult {
loadingChildren: boolean;
loadError: string | null;
retry: () => void;
}
/**
* Auto-fires `onLoadChildren` when the current folder's `children` is
* `undefined`. Per-folder loading + error state is keyed by folder id so
* navigating between folders surfaces the right state without a clear effect.
*/
export function useLazyLoad(args: UseLazyLoadArgs): UseLazyLoadResult {
const { currentFolderId, index, onLoadChildren } = args;
const [loadingIds, setLoadingIds] = useState<Set<string>>(
() => new Set(),
);
const [errorById, setErrorById] = useState<Map<string, string>>(
() => new Map(),
);
const inFlightRef = useRef<Set<string>>(new Set());
const triggerLoad = useCallback(
async (folderId: string) => {
if (!onLoadChildren) return;
if (inFlightRef.current.has(folderId)) return;
const node = index.get(folderId);
if (!node || node.type !== "folder") return;
inFlightRef.current.add(folderId);
setLoadingIds((prev) => {
const next = new Set(prev);
next.add(folderId);
return next;
});
setErrorById((prev) => {
if (!prev.has(folderId)) return prev;
const next = new Map(prev);
next.delete(folderId);
return next;
});
try {
await onLoadChildren({ nodeId: folderId, node });
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load";
setErrorById((prev) => {
const next = new Map(prev);
next.set(folderId, msg);
return next;
});
} finally {
inFlightRef.current.delete(folderId);
setLoadingIds((prev) => {
if (!prev.has(folderId)) return prev;
const next = new Set(prev);
next.delete(folderId);
return next;
});
}
},
[onLoadChildren, index],
);
useEffect(() => {
if (!onLoadChildren || currentFolderId === null) return;
const node = index.get(currentFolderId);
if (!node || node.type !== "folder") return;
if (node.children !== undefined) return;
if (errorById.has(currentFolderId)) return;
// Defer to the next microtask so the synchronous setLoadingIds inside
// triggerLoad doesn't violate react-hooks/set-state-in-effect (kickoff
// for async data fetch — sanctioned pattern).
queueMicrotask(() => {
void triggerLoad(currentFolderId);
});
}, [currentFolderId, index, onLoadChildren, triggerLoad, errorById]);
const retry = useCallback(() => {
if (currentFolderId === null) return;
setErrorById((prev) => {
if (!prev.has(currentFolderId)) return prev;
const next = new Map(prev);
next.delete(currentFolderId);
return next;
});
void triggerLoad(currentFolderId);
}, [currentFolderId, triggerLoad]);
const loadingChildren =
currentFolderId !== null && loadingIds.has(currentFolderId);
const loadError =
currentFolderId !== null
? (errorById.get(currentFolderId) ?? null)
: null;
return { loadingChildren, loadError, retry };
}
src/registry/components/navigation/file-manager/hooks/use-marquee.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { getItemRects, rectanglesIntersect, type Rect } from "../lib/intersect";
interface UseMarqueeArgs {
enabled: boolean;
/** Apply the marquee's current selection. `additive` = shift held (add to existing). */
applySelection: (ids: string[], additive: boolean) => void;
/** Snapshot the current selection at marquee-start so additive mode can restore + extend. */
snapshotSelection: () => Set<string>;
/** Container element used for both rect query + scrolling. */
containerRef: React.RefObject<HTMLDivElement | null>;
}
export interface MarqueeRect {
left: number;
top: number;
width: number;
height: number;
}
export interface UseMarqueeResult {
rect: MarqueeRect | null;
active: boolean;
/** Bind to the content pane's pointer-down — only fires when pointer-down lands on whitespace. */
onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void;
}
export function useMarquee(args: UseMarqueeArgs): UseMarqueeResult {
const { enabled, applySelection, snapshotSelection, containerRef } = args;
const [rect, setRect] = useState<MarqueeRect | null>(null);
const [active, setActive] = useState(false);
const startRef = useRef<{
x: number;
y: number;
additive: boolean;
snapshot: Set<string>;
} | null>(null);
// Stable refs to handlers so cleanup matches the registered listener.
const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
const upHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
// v0.1.1 (review): unmount cleanup — the window listeners were only
// removed in handleUp, so unmounting mid-drag leaked all three listeners
// (and the leaked move handler kept calling setState on the unmounted
// component).
useEffect(() => {
return () => {
if (moveHandlerRef.current) {
window.removeEventListener("pointermove", moveHandlerRef.current);
}
if (upHandlerRef.current) {
window.removeEventListener("pointerup", upHandlerRef.current);
window.removeEventListener("pointercancel", upHandlerRef.current);
}
moveHandlerRef.current = null;
upHandlerRef.current = null;
startRef.current = null;
};
}, []);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled) return;
if (e.button !== 0) return;
const additive = e.shiftKey;
const initialSelection = additive
? snapshotSelection()
: new Set<string>();
startRef.current = {
x: e.clientX,
y: e.clientY,
additive,
snapshot: initialSelection,
};
setActive(true);
const handleMove = (moveEvent: PointerEvent) => {
const start = startRef.current;
const container = containerRef.current;
if (!start || !container) return;
const containerRect = container.getBoundingClientRect();
const left = Math.min(start.x, moveEvent.clientX);
const top = Math.min(start.y, moveEvent.clientY);
const right = Math.max(start.x, moveEvent.clientX);
const bottom = Math.max(start.y, moveEvent.clientY);
setRect({
left: left - containerRect.left,
top: top - containerRect.top + container.scrollTop,
width: right - left,
height: bottom - top,
});
const marqueeRect: Rect = { left, top, right, bottom };
const itemRects = getItemRects(container);
const hit = new Set<string>();
for (const [id, ir] of itemRects) {
if (rectanglesIntersect(marqueeRect, ir)) hit.add(id);
}
const ids = Array.from(hit);
applySelection(
start.additive
? Array.from(new Set([...start.snapshot, ...ids]))
: ids,
start.additive,
);
};
const handleUp = () => {
startRef.current = null;
setActive(false);
setRect(null);
if (moveHandlerRef.current)
window.removeEventListener("pointermove", moveHandlerRef.current);
if (upHandlerRef.current) {
window.removeEventListener("pointerup", upHandlerRef.current);
window.removeEventListener("pointercancel", upHandlerRef.current);
}
moveHandlerRef.current = null;
upHandlerRef.current = null;
};
moveHandlerRef.current = handleMove;
upHandlerRef.current = handleUp;
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
window.addEventListener("pointercancel", handleUp);
},
[enabled, snapshotSelection, applySelection, containerRef],
);
return { rect, active, onPointerDown };
}
src/registry/components/navigation/file-manager/hooks/use-drag.ts
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DragEvent } from "react";
import type {
FileManagerDropPosition,
FileManagerExternalDropArgs,
FileManagerItem,
FileManagerMoveArgs,
FsNode,
} from "../types";
import { isLegalDrop } from "../lib/validation";
import { indexNodes } from "../lib/tree-utils";
/** Same MIME marker file-tree uses — enables cross-component drops. */
const INTERNAL_MIME = "application/x-ilinxa-file-tree";
const AUTO_SCROLL_EDGE_PX = 24;
const AUTO_SCROLL_PX_PER_FRAME = 4;
export interface DragOverState {
/** Item id currently being hovered as a drop target. `null` = container. */
itemId: string | null;
valid: boolean;
}
interface UseDragArgs {
nodes: FsNode[];
currentFolderId: string | null;
enableInternalDrag: boolean;
enableExternalDrop: boolean;
selectedIds: ReadonlySet<string>;
onMove?: (args: FileManagerMoveArgs) => void;
onExternalDrop?: (args: FileManagerExternalDropArgs) => void;
/** Replace selection with a single id (used at drag-start when source isn't selected). */
selectIds: (idOrIds: string | string[]) => void;
}
export interface UseDragResult {
dragOver: DragOverState | null;
isExternalDragging: boolean;
isInternalDragging: boolean;
// Item-level handlers
onItemDragStart: (e: DragEvent, item: FileManagerItem) => void;
onItemDragOver: (e: DragEvent, item: FileManagerItem) => void;
onItemDragLeave: (e: DragEvent, item: FileManagerItem) => void;
onItemDrop: (e: DragEvent, item: FileManagerItem) => void;
onItemDragEnd: (e: DragEvent, item: FileManagerItem) => void;
// Container handlers (scroll area)
onContainerDragEnter: (e: DragEvent) => void;
onContainerDragOver: (e: DragEvent) => void;
onContainerDragLeave: (e: DragEvent) => void;
onContainerDrop: (e: DragEvent) => void;
}
export function useDrag(args: UseDragArgs): UseDragResult {
const {
nodes,
currentFolderId,
enableInternalDrag,
enableExternalDrop,
selectedIds,
onMove,
onExternalDrop,
selectIds,
} = args;
const [dragOver, setDragOver] = useState<DragOverState | null>(null);
const [isExternalDragging, setIsExternalDragging] = useState(false);
const [isInternalDragging, setIsInternalDragging] = useState(false);
const isInternalDraggingRef = useRef(false);
const externalEnterCountRef = useRef(0);
const dragSourceIdsRef = useRef<string[]>([]);
const autoScrollRef = useRef<{
container: HTMLElement | null;
direction: number;
raf: number | null;
}>({ container: null, direction: 0, raf: null });
const nodeIndex = useMemo(() => indexNodes(nodes), [nodes]);
const stopAutoScroll = useCallback(() => {
const ref = autoScrollRef.current;
if (ref.raf !== null) {
cancelAnimationFrame(ref.raf);
ref.raf = null;
}
ref.direction = 0;
ref.container = null;
}, []);
const startAutoScroll = useCallback(
(container: HTMLElement | null, clientY: number) => {
if (!container) return;
const rect = container.getBoundingClientRect();
let dir = 0;
if (clientY - rect.top < AUTO_SCROLL_EDGE_PX) dir = -1;
else if (rect.bottom - clientY < AUTO_SCROLL_EDGE_PX) dir = 1;
const ref = autoScrollRef.current;
ref.container = container;
if (dir !== ref.direction) {
ref.direction = dir;
if (ref.raf !== null) {
cancelAnimationFrame(ref.raf);
ref.raf = null;
}
if (dir !== 0) {
const tick = () => {
const r = autoScrollRef.current;
if (r.container && r.direction !== 0) {
r.container.scrollTop +=
r.direction * AUTO_SCROLL_PX_PER_FRAME;
r.raf = requestAnimationFrame(tick);
} else {
r.raf = null;
}
};
ref.raf = requestAnimationFrame(tick);
}
}
},
[],
);
useEffect(() => {
return () => stopAutoScroll();
}, [stopAutoScroll]);
const onItemDragStart = useCallback(
(e: DragEvent, item: FileManagerItem) => {
if (!enableInternalDrag) return;
let ids: string[];
if (selectedIds.has(item.node.id) && selectedIds.size > 1) {
ids = Array.from(selectedIds);
} else {
ids = [item.node.id];
selectIds(item.node.id);
}
dragSourceIdsRef.current = ids;
isInternalDraggingRef.current = true;
setIsInternalDragging(true);
try {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(INTERNAL_MIME, "1");
} catch {
/* ignored */
}
},
[enableInternalDrag, selectedIds, selectIds],
);
const onItemDragEnd = useCallback(() => {
isInternalDraggingRef.current = false;
setIsInternalDragging(false);
dragSourceIdsRef.current = [];
setDragOver(null);
stopAutoScroll();
}, [stopAutoScroll]);
const onItemDragOver = useCallback(
(e: DragEvent, item: FileManagerItem) => {
if (!isInternalDraggingRef.current) return;
e.preventDefault();
// Drops are only legal on folders
const validTarget = item.node.type === "folder";
const position: FileManagerDropPosition = "inside";
const valid =
validTarget &&
isLegalDrop({
srcIds: dragSourceIdsRef.current,
targetId: item.node.id,
position,
index: nodeIndex,
});
e.dataTransfer.dropEffect = valid ? "move" : "none";
setDragOver({ itemId: item.node.id, valid });
const scroller = (e.currentTarget as HTMLElement).closest(
"[data-file-manager-scroller]",
) as HTMLElement | null;
startAutoScroll(scroller, e.clientY);
},
[nodeIndex, startAutoScroll],
);
const onItemDragLeave = useCallback(
(_e: DragEvent, item: FileManagerItem) => {
setDragOver((prev) =>
prev?.itemId === item.node.id ? null : prev,
);
},
[],
);
const onItemDrop = useCallback(
(e: DragEvent, item: FileManagerItem) => {
if (!isInternalDraggingRef.current) return;
e.preventDefault();
const validTarget = item.node.type === "folder";
const ids = dragSourceIdsRef.current;
if (!validTarget) {
// rejected
setDragOver(null);
stopAutoScroll();
isInternalDraggingRef.current = false;
setIsInternalDragging(false);
dragSourceIdsRef.current = [];
return;
}
const valid = isLegalDrop({
srcIds: ids,
targetId: item.node.id,
position: "inside",
index: nodeIndex,
});
if (valid && onMove) {
onMove({ ids, targetId: item.node.id, position: "inside" });
}
setDragOver(null);
stopAutoScroll();
isInternalDraggingRef.current = false;
setIsInternalDragging(false);
dragSourceIdsRef.current = [];
},
[nodeIndex, onMove, stopAutoScroll],
);
// Container-level (whitespace) handlers
const hasFiles = (e: DragEvent) =>
Array.from(e.dataTransfer.types).includes("Files");
const onContainerDragEnter = useCallback(
(e: DragEvent) => {
if (!enableExternalDrop) return;
if (!hasFiles(e)) return;
externalEnterCountRef.current += 1;
setIsExternalDragging(true);
},
[enableExternalDrop],
);
const onContainerDragOver = useCallback(
(e: DragEvent) => {
if (enableExternalDrop && hasFiles(e)) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
} else if (isInternalDraggingRef.current) {
// v0.1.1 (review 5.7): this handler sits on an ANCESTOR of the item
// elements, so every item dragover bubbled here and cleared the drop
// target the item handler had just set (highlight never rendered,
// cursor read "no-drop" over legal folders). Only treat the event as
// a whitespace hover when it did NOT originate inside an item.
const target = e.target as HTMLElement | null;
if (target?.closest("[data-item-id]")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "none"; // dropping on whitespace internal = no-op
setDragOver(null);
// auto-scroll if near edges
const scroller = e.currentTarget as HTMLElement;
startAutoScroll(scroller, e.clientY);
}
},
[enableExternalDrop, startAutoScroll],
);
const onContainerDragLeave = useCallback(
(e: DragEvent) => {
if (!enableExternalDrop) return;
if (!hasFiles(e)) return;
externalEnterCountRef.current = Math.max(
0,
externalEnterCountRef.current - 1,
);
if (externalEnterCountRef.current === 0) {
setIsExternalDragging(false);
}
},
[enableExternalDrop],
);
const onContainerDrop = useCallback(
(e: DragEvent) => {
if (enableExternalDrop && hasFiles(e)) {
e.preventDefault();
externalEnterCountRef.current = 0;
setIsExternalDragging(false);
const target = e.target as HTMLElement | null;
const itemEl = target?.closest("[data-item-id]") as
| HTMLElement
| null;
const itemId = itemEl?.getAttribute("data-item-id") ?? null;
let targetFolderId: string | null = currentFolderId;
if (itemId) {
const node = nodeIndex.get(itemId);
if (node?.type === "folder") targetFolderId = itemId;
}
const files = Array.from(e.dataTransfer.files ?? []);
if (files.length > 0) {
onExternalDrop?.({ files, targetFolderId });
}
return;
}
// Internal drag onto whitespace = no-op
if (isInternalDraggingRef.current) {
e.preventDefault();
isInternalDraggingRef.current = false;
setIsInternalDragging(false);
dragSourceIdsRef.current = [];
setDragOver(null);
stopAutoScroll();
}
},
[
enableExternalDrop,
currentFolderId,
nodeIndex,
onExternalDrop,
stopAutoScroll,
],
);
return {
dragOver,
isExternalDragging,
isInternalDragging,
onItemDragStart,
onItemDragOver,
onItemDragLeave,
onItemDrop,
onItemDragEnd,
onContainerDragEnter,
onContainerDragOver,
onContainerDragLeave,
onContainerDrop,
};
}
src/registry/components/navigation/file-manager/hooks/use-keyboard.ts
"use client";
import { useCallback, useEffect, useRef } from "react";
import type { KeyboardEvent } from "react";
import type {
FileManagerActions,
FileManagerItem,
FileManagerOpenArgs,
FileManagerViewMode,
} from "../types";
interface UseKeyboardArgs {
items: FileManagerItem[];
focusedId: string | null;
renamingId: string | null;
selectedIds: ReadonlySet<string>;
viewMode: FileManagerViewMode;
/** Live ref to the current column count in grid mode (computed by content-pane). */
columnsRef: React.MutableRefObject<number>;
actions: FileManagerActions;
onOpen?: (args: FileManagerOpenArgs) => void;
}
const SUPPRESS_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
export interface UseKeyboardResult {
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => void;
}
export function useKeyboard(args: UseKeyboardArgs): UseKeyboardResult {
const {
items,
focusedId,
renamingId,
selectedIds,
viewMode,
columnsRef,
actions,
onOpen,
} = args;
// Type-ahead state
const typeAheadRef = useRef<{ prefix: string; timer: number | null }>({
prefix: "",
timer: null,
});
// v0.1.1 (review): clear the pending type-ahead reset timer on unmount.
// (The ref object's identity is stable — only its fields mutate.)
useEffect(() => {
const ta = typeAheadRef.current;
return () => {
if (ta.timer !== null) window.clearTimeout(ta.timer);
ta.timer = null;
ta.prefix = "";
};
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (target && SUPPRESS_TAGS.includes(target.tagName)) return;
if (renamingId) return;
const isMod = e.metaKey || e.ctrlKey;
const idx = items.findIndex((item) => item.node.id === focusedId);
const noFocus = idx === -1;
const cols = Math.max(1, columnsRef.current || 1);
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
if (noFocus) {
if (items[0]) actions.focusNode(items[0].node.id);
return;
}
const step = viewMode === "grid" ? cols : 1;
const next = Math.min(items.length - 1, idx + step);
if (items[next]) actions.focusNode(items[next].node.id);
return;
}
case "ArrowUp": {
e.preventDefault();
if (noFocus) {
if (items[0]) actions.focusNode(items[0].node.id);
return;
}
const step = viewMode === "grid" ? cols : 1;
const next = Math.max(0, idx - step);
if (items[next]) actions.focusNode(items[next].node.id);
return;
}
case "ArrowRight": {
if (viewMode !== "grid") return;
e.preventDefault();
if (noFocus) {
if (items[0]) actions.focusNode(items[0].node.id);
return;
}
const next = Math.min(items.length - 1, idx + 1);
if (items[next]) actions.focusNode(items[next].node.id);
return;
}
case "ArrowLeft": {
if (viewMode !== "grid") return;
e.preventDefault();
if (noFocus) {
if (items[0]) actions.focusNode(items[0].node.id);
return;
}
const next = Math.max(0, idx - 1);
if (items[next]) actions.focusNode(items[next].node.id);
return;
}
case "Home": {
e.preventDefault();
if (items[0]) actions.focusNode(items[0].node.id);
return;
}
case "End": {
e.preventDefault();
const last = items[items.length - 1];
if (last) actions.focusNode(last.node.id);
return;
}
case "Enter": {
if (noFocus) return;
e.preventDefault();
const item = items[idx];
if (item.node.type === "folder") {
actions.navigateTo(item.node.id);
} else {
onOpen?.({ node: item.node });
}
return;
}
case " ":
case "Spacebar": {
if (noFocus) return;
e.preventDefault();
actions.toggleSelection(items[idx].node.id);
return;
}
case "F2": {
if (noFocus) return;
e.preventDefault();
actions.startRename(items[idx].node.id);
return;
}
case "Delete": {
if (selectedIds.size === 0) return;
e.preventDefault();
actions.triggerDelete(Array.from(selectedIds));
return;
}
case "Backspace": {
if (selectedIds.size > 0) {
e.preventDefault();
actions.triggerDelete(Array.from(selectedIds));
} else {
e.preventDefault();
actions.navigateUp();
}
return;
}
case "Escape": {
if (selectedIds.size > 0) {
e.preventDefault();
actions.clearSelection();
}
return;
}
case "a":
case "A": {
if (!isMod) break;
e.preventDefault();
actions.selectAll();
return;
}
case "x":
case "X": {
if (!isMod) break;
if (selectedIds.size === 0) return;
e.preventDefault();
actions.cut(Array.from(selectedIds));
return;
}
case "c":
case "C": {
if (!isMod) break;
if (selectedIds.size === 0) return;
e.preventDefault();
actions.copy(Array.from(selectedIds));
return;
}
case "v":
case "V": {
if (!isMod) break;
e.preventDefault();
actions.paste();
return;
}
case "[": {
if (!isMod) break;
e.preventDefault();
actions.navigateBack();
return;
}
case "]": {
if (!isMod) break;
e.preventDefault();
actions.navigateForward();
return;
}
}
// Type-ahead: a single printable character (no modifier)
if (!isMod && e.key.length === 1 && /\S/.test(e.key)) {
const ta = typeAheadRef.current;
ta.prefix += e.key.toLowerCase();
if (ta.timer !== null) window.clearTimeout(ta.timer);
ta.timer = window.setTimeout(() => {
ta.prefix = "";
ta.timer = null;
}, 800);
const match = items.find((item) =>
item.node.name.toLowerCase().startsWith(ta.prefix),
);
if (match) {
e.preventDefault();
actions.focusNode(match.node.id);
}
}
},
[
items,
focusedId,
renamingId,
selectedIds,
viewMode,
columnsRef,
actions,
onOpen,
],
);
return { onKeyDown };
}
src/registry/components/navigation/file-manager/lib/icons.tsx
import type { ReactNode } from "react";
import {
File,
FileArchive,
FileAudio,
FileCode,
FileCode2,
FileImage,
FileJson,
FileText,
FileVideo,
Folder,
FolderOpen,
} from "lucide-react";
import type { FsNode } from "../types";
const EXT_TO_ICON: Record<string, typeof File> = {
ts: FileCode,
tsx: FileCode,
js: FileCode,
jsx: FileCode,
mjs: FileCode,
cjs: FileCode,
py: FileCode,
rb: FileCode,
go: FileCode,
rs: FileCode,
java: FileCode,
kt: FileCode,
swift: FileCode,
cpp: FileCode,
c: FileCode,
h: FileCode,
cs: FileCode,
php: FileCode,
lua: FileCode,
css: FileCode,
scss: FileCode,
sass: FileCode,
less: FileCode,
html: FileCode2,
htm: FileCode2,
xml: FileCode2,
json: FileJson,
jsonc: FileJson,
md: FileText,
mdx: FileText,
markdown: FileText,
txt: FileText,
rtf: FileText,
png: FileImage,
jpg: FileImage,
jpeg: FileImage,
gif: FileImage,
webp: FileImage,
bmp: FileImage,
ico: FileImage,
svg: FileImage,
zip: FileArchive,
tar: FileArchive,
gz: FileArchive,
"7z": FileArchive,
rar: FileArchive,
mp4: FileVideo,
mov: FileVideo,
webm: FileVideo,
mkv: FileVideo,
avi: FileVideo,
mp3: FileAudio,
wav: FileAudio,
ogg: FileAudio,
flac: FileAudio,
lock: File,
env: File,
gitignore: File,
gitattributes: File,
editorconfig: File,
};
export function getNodeExtension(node: FsNode): string {
if (node.ext) return node.ext.replace(/^\./, "").toLowerCase();
const idx = node.name.lastIndexOf(".");
if (idx <= 0 || idx === node.name.length - 1) return "";
return node.name.slice(idx + 1).toLowerCase();
}
export function iconForExtension(
ext: string,
className = "size-4",
): ReactNode {
const normalized = ext.replace(/^\./, "").toLowerCase();
const Icon = EXT_TO_ICON[normalized] ?? File;
return <Icon className={className} />;
}
export function defaultFileIcon(node: FsNode, className = "size-4"): ReactNode {
return iconForExtension(getNodeExtension(node), className);
}
export function defaultFolderIcon(
expanded: boolean,
className = "size-4",
): ReactNode {
const Icon = expanded ? FolderOpen : Folder;
return <Icon className={className} />;
}
src/registry/components/navigation/file-manager/lib/tree-utils.ts
import type { FsNode } from "../types";
/** Flat-index a hierarchical array by id. */
export function indexNodes(nodes: FsNode[]): Map<string, FsNode> {
const map = new Map<string, FsNode>();
const walk = (list: FsNode[]) => {
for (const node of list) {
if (map.has(node.id)) continue;
map.set(node.id, node);
if (node.children) walk(node.children);
}
};
walk(nodes);
return map;
}
export function findNode(
id: string,
nodes: FsNode[],
): FsNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const hit = findNode(id, node.children);
if (hit) return hit;
}
}
return undefined;
}
export function getParentChain(
nodeId: string,
index: Map<string, FsNode>,
): FsNode[] {
const chain: FsNode[] = [];
let current = index.get(nodeId);
while (current?.parentId) {
const parent = index.get(current.parentId);
if (!parent) break;
chain.unshift(parent);
current = parent;
}
return chain;
}
export function getDescendantIds(
nodeId: string,
index: Map<string, FsNode>,
): Set<string> {
const out = new Set<string>();
const node = index.get(nodeId);
if (!node?.children) return out;
const walk = (list: FsNode[]) => {
for (const child of list) {
if (out.has(child.id)) continue;
out.add(child.id);
if (child.children) walk(child.children);
}
};
walk(node.children);
return out;
}
/** Default sort: folders first, then case-insensitive locale alpha by name. */
export function defaultSort(a: FsNode, b: FsNode): number {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
}
/** Children of a given folder id; null = root-level nodes. */
export function getChildrenOf(
folderId: string | null,
nodes: FsNode[],
index: Map<string, FsNode>,
): FsNode[] | undefined {
if (folderId === null) return nodes;
const folder = index.get(folderId);
if (!folder) return undefined;
if (folder.type !== "folder") return undefined;
return folder.children;
}
function replaceChildren(
nodes: FsNode[],
parentId: string,
next: FsNode[],
): FsNode[] {
let changed = false;
const out = nodes.map((node) => {
if (node.id === parentId) {
changed = true;
return { ...node, children: next };
}
if (node.children) {
const replaced = replaceChildren(node.children, parentId, next);
if (replaced !== node.children) {
changed = true;
return { ...node, children: replaced };
}
}
return node;
});
return changed ? out : nodes;
}
/** Public consumer helper for splicing lazy-loaded children into the tree. */
export function mergeLoadedChildren(
nodes: FsNode[],
parentId: string,
children: FsNode[],
): FsNode[] {
return replaceChildren(nodes, parentId, children);
}
export function countAllNodes(nodes: FsNode[]): number {
let total = 0;
const walk = (list: FsNode[]) => {
for (const node of list) {
total += 1;
if (node.children) walk(node.children);
}
};
walk(nodes);
return total;
}
src/registry/components/navigation/file-manager/lib/validation.ts
import type { FsNode, FileManagerDropPosition } from "../types";
export function isSelfDrop(
srcIds: string[],
targetId: string | null,
): boolean {
if (!targetId) return false;
return srcIds.includes(targetId);
}
export function isCycle(
srcIds: string[],
targetId: string | null,
index: Map<string, FsNode>,
): boolean {
if (!targetId) return false;
const set = new Set(srcIds);
let current: FsNode | undefined = index.get(targetId);
while (current) {
if (set.has(current.id)) return true;
const parentId = current.parentId;
if (!parentId) break;
current = index.get(parentId);
}
return false;
}
export interface IsLegalDropArgs {
srcIds: string[];
targetId: string | null;
position: FileManagerDropPosition;
index: Map<string, FsNode>;
}
export function isLegalDrop({
srcIds,
targetId,
position,
index,
}: IsLegalDropArgs): boolean {
if (srcIds.length === 0) return false;
if (isSelfDrop(srcIds, targetId)) return false;
if (position === "inside" && isCycle(srcIds, targetId, index)) return false;
return true;
}
/** Soft-validate a node tree on mount. */
export function validateNodes(nodes: FsNode[]): string[] {
const warnings: string[] = [];
const ids = new Set<string>();
const walk = (list: FsNode[], path: Set<string>) => {
for (const node of list) {
if (ids.has(node.id)) {
warnings.push(`duplicate node id: "${node.id}"`);
continue;
}
ids.add(node.id);
if (path.has(node.id)) {
warnings.push(`cycle detected at node: "${node.id}"`);
continue;
}
if (node.children) {
const next = new Set(path);
next.add(node.id);
walk(node.children, next);
}
}
};
walk(nodes, new Set());
return warnings;
}
src/registry/components/navigation/file-manager/lib/path.ts
import type { FsNode } from "../types";
import { getParentChain } from "./tree-utils";
/**
* Build the path from root → ... → currentFolder. Empty array when at root
* (`currentFolderId === null`). Returns the chain of FsNodes.
*/
export function buildPath(
currentFolderId: string | null,
index: Map<string, FsNode>,
): FsNode[] {
if (currentFolderId === null) return [];
const current = index.get(currentFolderId);
if (!current) return [];
const chain = getParentChain(currentFolderId, index);
return [...chain, current];
}
/**
* Best-effort string-to-id resolution. Looks for a node whose name path
* (from root) matches the typed segments. Returns the id of the matching
* folder, or `null` if no match.
*
* Consumer typically provides their own resolver via `onPathTyped`; this
* helper is for the simple case where path segments map directly to node
* names.
*/
export function parsePath(
path: string,
nodes: FsNode[],
): string | null {
const segments = path.split(/[/\\]/).filter(Boolean);
if (segments.length === 0) return null;
let current: FsNode[] | undefined = nodes;
let lastMatchId: string | null = null;
for (const segment of segments) {
if (!current) return null;
const match: FsNode | undefined = current.find(
(n: FsNode) =>
n.type === "folder" &&
n.name.localeCompare(segment, undefined, { sensitivity: "base" }) ===
0,
);
if (!match) return null;
lastMatchId = match.id;
current = match.children;
}
return lastMatchId;
}
src/registry/components/navigation/file-manager/lib/intersect.ts
export interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
/** AABB rectangle intersection. */
export function rectanglesIntersect(a: Rect, b: Rect): boolean {
return !(
a.right < b.left ||
a.left > b.right ||
a.bottom < b.top ||
a.top > b.bottom
);
}
/**
* Collect bounding rects of every item currently rendered inside `scroller`,
* keyed by `data-item-id`. Used by the marquee hook to determine which items
* the rectangle currently covers.
*
* Rects are in viewport coordinates (`getBoundingClientRect`); callers
* comparing against the marquee rectangle should use the same coordinate
* space.
*/
export function getItemRects(
scroller: HTMLElement | null,
): Map<string, Rect> {
const out = new Map<string, Rect>();
if (!scroller) return out;
const items = scroller.querySelectorAll<HTMLElement>("[data-item-id]");
for (const el of items) {
const id = el.getAttribute("data-item-id");
if (!id) continue;
const rect = el.getBoundingClientRect();
out.set(id, {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}
return out;
}
src/registry/components/navigation/file-manager/lib/format.ts
/** Format bytes as a human-readable string ("4.2 MB", "812 KB", "32 B"). */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded} ${units[unit]}`;
}
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
/** Format an ISO date string as "May 9, 2026". Returns the input if unparseable. */
export function formatDate(iso: string | undefined): string {
if (!iso) return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return dateFormatter.format(date);
}
/** Capitalize an extension for display in the list view's Type column. */
export function formatKind(ext: string, type: "file" | "folder"): string {
if (type === "folder") return "Folder";
if (!ext) return "File";
return ext.toUpperCase();
}
/** Plural-aware count: "1 item" / "5 items". */
export function formatItemCount(n: number, label = "items"): string {
return `${n} ${n === 1 ? label.replace(/s$/, "") : label}`;
}
src/registry/components/navigation/file-manager/parts/file-manager-toolbar.tsx
"use client";
import type { FileManagerPathTypedArgs } from "../types";
import { useFileManager } from "../hooks/use-file-manager-context";
import { FileManagerBackForward } from "./file-manager-back-forward";
import { FileManagerUpButton } from "./file-manager-up-button";
import { FileManagerPathBar } from "./file-manager-path-bar";
import { FileManagerViewToggle } from "./file-manager-view-toggle";
import { FileManagerIconSizeControl } from "./file-manager-icon-size-control";
import { FileManagerSortMenu } from "./file-manager-sort-menu";
import { FileManagerSearchInput } from "./file-manager-search-input";
import { FileManagerNewButtons } from "./file-manager-new-buttons";
import { FileManagerRefreshButton } from "./file-manager-refresh-button";
export interface FileManagerToolbarProps {
onPathTyped?: (args: FileManagerPathTypedArgs) => void;
}
export function FileManagerToolbar(props: FileManagerToolbarProps) {
const { onPathTyped } = props;
const {
showBackForward,
showUpButton,
showPathBar,
showSearch,
showSort,
showIconSize,
showViewToggle,
showNewFile,
showNewFolder,
showRefresh,
} = useFileManager();
return (
<div
role="toolbar"
className="flex h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-card/40 px-2"
>
{showBackForward ? <FileManagerBackForward /> : null}
{showUpButton ? <FileManagerUpButton /> : null}
{showPathBar ? <FileManagerPathBar onPathTyped={onPathTyped} /> : null}
{showSearch ? <FileManagerSearchInput /> : null}
{showSort ? <FileManagerSortMenu /> : null}
{showIconSize ? <FileManagerIconSizeControl /> : null}
{showViewToggle ? <FileManagerViewToggle /> : null}
{(showNewFile || showNewFolder) ? <FileManagerNewButtons /> : null}
{showRefresh ? <FileManagerRefreshButton /> : null}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-back-forward.tsx
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerBackForward() {
const { actions, state, labels } = useFileManager();
const canBack = state.historyBackIds.length > 0;
const canForward = state.historyForwardIds.length > 0;
// F-cross-13: no `asChild` — Base UI triggers reject it; triggers ARE the buttons, styled via buttonVariants.
const triggerClass = cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-7",
);
return (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger
type="button"
className={triggerClass}
disabled={!canBack}
onClick={() => actions.navigateBack()}
aria-label={labels.back}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.back}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
type="button"
className={triggerClass}
disabled={!canForward}
onClick={() => actions.navigateForward()}
aria-label={labels.forward}
>
<ChevronRight className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.forward}</TooltipContent>
</Tooltip>
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-up-button.tsx
"use client";
import { ArrowUp } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerUpButton() {
const { actions, state, labels } = useFileManager();
const atRoot = state.currentFolderId === null;
// F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.
return (
<Tooltip>
<TooltipTrigger
type="button"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "size-7")}
disabled={atRoot}
onClick={() => actions.navigateUp()}
aria-label={labels.up}
>
<ArrowUp className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.up}</TooltipContent>
</Tooltip>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-path-bar.tsx
"use client";
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { ChevronRight, Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
import type { FileManagerPathTypedArgs } from "../types";
export interface FileManagerPathBarProps {
/** Optional callback when user types a path; if omitted, the bar stays in display mode. */
onPathTyped?: (args: FileManagerPathTypedArgs) => void;
className?: string;
}
export function FileManagerPathBar(props: FileManagerPathBarProps) {
const { onPathTyped, className } = props;
const { state, actions, labels } = useFileManager();
const { path } = state;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const display = path.map((n) => n.name).join("/");
const startEdit = (e: ReactMouseEvent) => {
if (!onPathTyped) return;
// Only enter edit mode when clicking the empty area (not a segment)
const target = e.target as HTMLElement | null;
if (target?.closest("[data-path-segment]")) return;
setDraft(display);
setEditing(true);
};
const commit = () => {
if (draft.trim() === display.trim()) {
setEditing(false);
return;
}
onPathTyped?.({ path: draft });
setEditing(false);
};
const onInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
};
if (editing) {
return (
<div className={cn("flex flex-1 items-center", className)}>
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onInputKeyDown}
className="h-7 w-full rounded-sm border border-border bg-background px-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Path"
/>
</div>
);
}
return (
<nav
aria-label="Path"
onClick={startEdit}
className={cn(
"flex min-w-0 flex-1 cursor-text items-center gap-0.5 truncate rounded-sm px-1 py-0.5 text-xs",
onPathTyped && "hover:bg-muted/40",
className,
)}
>
<button
type="button"
data-path-segment="root"
onClick={(e) => {
e.stopPropagation();
actions.navigateTo(null);
}}
className="rounded-sm px-1 py-0.5 font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{labels.pathRoot}
</button>
{path.map((segment) => (
<span key={segment.id} className="flex shrink-0 items-center gap-0.5">
<ChevronRight
className="size-3 text-muted-foreground/60"
aria-hidden="true"
/>
<button
type="button"
data-path-segment={segment.id}
onClick={(e) => {
e.stopPropagation();
actions.navigateTo(segment.id);
}}
className="max-w-[180px] truncate rounded-sm px-1 py-0.5 text-foreground hover:bg-muted/60"
>
{segment.name}
</button>
</span>
))}
{onPathTyped ? (
<Pencil
aria-hidden="true"
className="ml-auto size-3 shrink-0 text-muted-foreground/40"
/>
) : null}
</nav>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-view-toggle.tsx
"use client";
import { LayoutGrid, List } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
import type { FileManagerViewMode } from "../types";
/**
* Plain-button segmented control. Deliberately NOT shadcn ToggleGroup — its
* value model diverges between Radix (single: string) and Base UI (string[]),
* an F-cross-13 carrier (event-calendar v0.2.1 precedent; toggle-group dep
* dropped). Each segment is the TooltipTrigger itself — no `asChild`, which
* Base UI triggers reject.
*/
export function FileManagerViewToggle() {
const { actions, state, labels } = useFileManager();
const modes: Array<{
mode: FileManagerViewMode;
label: string;
Icon: typeof LayoutGrid;
}> = [
{ mode: "grid", label: labels.viewGrid, Icon: LayoutGrid },
{ mode: "list", label: labels.viewList, Icon: List },
];
return (
<div role="group" className="flex shrink-0 items-center">
{modes.map(({ mode, label, Icon }) => (
<Tooltip key={mode}>
<TooltipTrigger
type="button"
aria-pressed={state.viewMode === mode}
aria-label={label}
onClick={() => actions.setViewMode(mode)}
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-7 rounded-none first:rounded-l-lg last:rounded-r-lg",
state.viewMode === mode && "bg-muted",
)}
>
<Icon className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-icon-size-control.tsx
"use client";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
import type { FileManagerIconSize } from "../types";
/**
* Plain-button segmented control. Deliberately NOT shadcn ToggleGroup — its
* value model diverges between Radix (single: string) and Base UI (string[]),
* an F-cross-13 carrier (event-calendar v0.2.1 precedent; toggle-group dep
* dropped). Each segment is the TooltipTrigger itself — no `asChild`, which
* Base UI triggers reject.
*/
export function FileManagerIconSizeControl() {
const { actions, state, labels } = useFileManager();
if (state.viewMode !== "grid") return null;
const dotSize: Record<FileManagerIconSize, string> = {
sm: "size-1.5",
md: "size-2",
lg: "size-2.5",
};
return (
<div role="group" className="flex shrink-0 items-center">
{(["sm", "md", "lg"] as const).map((size) => {
const label =
size === "sm"
? labels.iconSizeSmall
: size === "md"
? labels.iconSizeMedium
: labels.iconSizeLarge;
return (
<Tooltip key={size}>
<TooltipTrigger
type="button"
aria-pressed={state.iconSize === size}
aria-label={label}
onClick={() => actions.setIconSize(size)}
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-7 rounded-none first:rounded-l-lg last:rounded-r-lg",
state.iconSize === size && "bg-muted",
)}
>
<span
className={`rounded-full bg-current ${dotSize[size]}`}
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-sort-menu.tsx
"use client";
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
import type { FileManagerSortKey } from "../types";
export function FileManagerSortMenu() {
const { actions, state, labels } = useFileManager();
const sortKeys: { key: FileManagerSortKey; label: string }[] = [
{ key: "name", label: labels.sortByName },
{ key: "modified", label: labels.sortByModified },
{ key: "size", label: labels.sortBySize },
{ key: "type", label: labels.sortByType },
];
// F-cross-13: no `asChild` — Base UI triggers reject it. The Tooltip wrapper
// is dropped: TooltipTrigger renders its own <button> in both backends, and
// nesting it with DropdownMenuTrigger would need asChild/render to fuse them
// into one element. `title` keeps a hover affordance natively.
return (
<DropdownMenu>
<DropdownMenuTrigger
type="button"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "size-7")}
aria-label={`Sort by ${state.sort.key}`}
title="Sort"
>
<ArrowUpDown className="size-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
{sortKeys.map(({ key, label }) => (
<DropdownMenuItem
key={key}
onSelect={() =>
actions.setSort({
key,
order:
state.sort.key === key && state.sort.order === "asc"
? "desc"
: "asc",
})
}
>
{state.sort.key === key ? (
<Check className="size-3.5" aria-hidden="true" />
) : (
<span aria-hidden="true" className="size-3.5" />
)}
{label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
actions.setSort({
key: state.sort.key,
order: state.sort.order === "asc" ? "desc" : "asc",
})
}
>
{state.sort.order === "asc" ? (
<ArrowUp className="size-3.5" aria-hidden="true" />
) : (
<ArrowDown className="size-3.5" aria-hidden="true" />
)}
{state.sort.order === "asc" ? labels.sortAsc : labels.sortDesc}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-search-input.tsx
"use client";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerSearchInput() {
const { actions, state, labels } = useFileManager();
return (
<div className="relative flex w-48 shrink-0 items-center">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2 size-3.5 text-muted-foreground"
/>
<Input
type="search"
value={state.searchQuery}
onChange={(e) => actions.setSearchQuery(e.target.value)}
placeholder={labels.searchPlaceholder}
aria-label={labels.searchPlaceholder}
className="h-7 pl-7 pr-7 text-xs"
/>
{state.searchQuery ? (
<button
type="button"
onClick={() => actions.setSearchQuery("")}
aria-label="Clear search"
className="absolute right-2 rounded-sm text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" aria-hidden="true" />
</button>
) : null}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-new-buttons.tsx
"use client";
import { FilePlus, FolderPlus } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerNewButtons() {
const { actions, showNewFile, showNewFolder, labels } = useFileManager();
if (!showNewFile && !showNewFolder) return null;
// F-cross-13: no `asChild` — Base UI triggers reject it; triggers ARE the buttons, styled via buttonVariants.
const triggerClass = cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-7",
);
return (
<div className="flex shrink-0 items-center gap-0.5">
{showNewFile ? (
<Tooltip>
<TooltipTrigger
type="button"
className={triggerClass}
onClick={() => actions.triggerCreate("file")}
aria-label={labels.newFile}
>
<FilePlus className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.newFile}</TooltipContent>
</Tooltip>
) : null}
{showNewFolder ? (
<Tooltip>
<TooltipTrigger
type="button"
className={triggerClass}
onClick={() => actions.triggerCreate("folder")}
aria-label={labels.newFolder}
>
<FolderPlus className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.newFolder}</TooltipContent>
</Tooltip>
) : null}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-refresh-button.tsx
"use client";
import { RefreshCw } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerRefreshButton() {
const { actions, state, labels, showRefresh } = useFileManager();
if (!showRefresh) return null;
// F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.
return (
<Tooltip>
<TooltipTrigger
type="button"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "size-7")}
onClick={() => actions.refresh(state.currentFolderId)}
aria-label={labels.refresh}
>
<RefreshCw className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{labels.refresh}</TooltipContent>
</Tooltip>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-content-pane.tsx
"use client";
import {
useCallback,
useEffect,
type DragEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
type RefObject,
} from "react";
import type {
FileManagerIconSize,
FileManagerItem,
FileManagerItemRenderArgs,
FileManagerLabels,
FileManagerSortState,
FileManagerValidateRenameArgs,
FileManagerViewMode,
FileManagerVirtualizeMode,
FsNode,
} from "../types";
import type { MarqueeRect } from "../hooks/use-marquee";
import { FileManagerGridView } from "./file-manager-grid-view";
import { FileManagerListView } from "./file-manager-list-view";
import { FileManagerMarquee } from "./file-manager-marquee";
import { FileManagerEmpty } from "./file-manager-empty";
import { FileManagerLoading } from "./file-manager-loading";
interface FileManagerContentPaneProps {
items: FileManagerItem[];
viewMode: FileManagerViewMode;
iconSize: FileManagerIconSize;
rowHeight: number;
sort: FileManagerSortState;
onSetSort: (next: FileManagerSortState) => void;
renamingId: string | null;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
iconForNode?: (args: { node: FsNode }) => ReactNode;
enableInternalDrag: boolean;
enableMarqueeSelection: boolean;
dropTargetId: string | null;
dropInvalid: boolean;
loading: boolean;
loadError: string | null;
onRetryLoad: () => void;
isEmpty: boolean;
marqueeRect: MarqueeRect | null;
marqueeActive: boolean;
onMarqueePointerDown: (e: PointerEvent<HTMLDivElement>) => void;
scrollRef: RefObject<HTMLDivElement | null>;
/** Computed columns ref for keyboard 2D-arrow nav. */
columnsRef: React.MutableRefObject<number>;
/** Virtualization config — applied by list view internally. */
virtualize: FileManagerVirtualizeMode;
virtualizeThreshold: number;
renderItem?: (args: FileManagerItemRenderArgs) => ReactNode;
renderEmpty?: () => ReactNode;
renderLoading?: () => ReactNode;
labels: FileManagerLabels;
// Item-level callbacks
onItemClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemDoubleClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemContextMenu: (e: MouseEvent, item: FileManagerItem) => void;
onRenameCommit: (id: string, nextName: string) => void;
onRenameCancel: () => void;
onItemDragStart?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragOver?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragLeave?: (e: DragEvent, item: FileManagerItem) => void;
onItemDrop?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragEnd?: (e: DragEvent, item: FileManagerItem) => void;
// Container handlers (for whitespace context-menu + drop)
onContainerContextMenu?: (e: MouseEvent) => void;
onContainerDragEnter?: (e: DragEvent) => void;
onContainerDragOver?: (e: DragEvent) => void;
onContainerDragLeave?: (e: DragEvent) => void;
onContainerDrop?: (e: DragEvent) => void;
}
const TRACK_PX_BY_SIZE: Record<FileManagerIconSize, number> = {
sm: 80,
md: 120,
lg: 180,
};
export function FileManagerContentPane(props: FileManagerContentPaneProps) {
const {
items,
viewMode,
iconSize,
rowHeight,
sort,
onSetSort,
renamingId,
validateRename,
iconForNode,
enableInternalDrag,
enableMarqueeSelection,
dropTargetId,
dropInvalid,
loading,
loadError,
onRetryLoad,
isEmpty,
marqueeRect,
marqueeActive,
onMarqueePointerDown,
scrollRef,
columnsRef,
virtualize,
virtualizeThreshold,
renderItem,
renderEmpty,
renderLoading,
labels,
onItemClick,
onItemDoubleClick,
onItemContextMenu,
onRenameCommit,
onRenameCancel,
onItemDragStart,
onItemDragOver,
onItemDragLeave,
onItemDrop,
onItemDragEnd,
onContainerContextMenu,
onContainerDragEnter,
onContainerDragOver,
onContainerDragLeave,
onContainerDrop,
} = props;
// Compute column count for keyboard arrow nav (grid mode)
const recomputeColumns = useCallback(() => {
const el = scrollRef.current;
if (!el || viewMode !== "grid") {
columnsRef.current = 1;
return;
}
const trackPx = TRACK_PX_BY_SIZE[iconSize];
const gap = 4; // matches `gap-1` in grid view
const availableWidth = el.clientWidth - 16; // p-2 padding
const cols = Math.max(
1,
Math.floor((availableWidth + gap) / (trackPx + gap)),
);
columnsRef.current = cols;
}, [scrollRef, viewMode, iconSize, columnsRef]);
useEffect(() => {
recomputeColumns();
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(recomputeColumns);
ro.observe(el);
return () => ro.disconnect();
}, [recomputeColumns, scrollRef]);
// Pointer-down dispatch — only fire marquee if NOT on an item
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (!enableMarqueeSelection) return;
const target = e.target as HTMLElement | null;
if (target?.closest("[data-item-id]")) return; // item handles its own drag
onMarqueePointerDown(e);
};
return (
<div
ref={scrollRef}
data-file-manager-scroller
className="relative h-full flex-1 overflow-y-auto overflow-x-hidden"
onPointerDown={handlePointerDown}
onContextMenu={onContainerContextMenu}
onDragEnter={onContainerDragEnter}
onDragOver={onContainerDragOver}
onDragLeave={onContainerDragLeave}
onDrop={onContainerDrop}
style={{ userSelect: marqueeActive ? "none" : undefined }}
>
{loading ? (
renderLoading?.() ?? <FileManagerLoading />
) : loadError ? (
<div className="flex h-full items-center justify-center px-4 py-8 text-center">
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">
{labels.loadError}
</p>
<p className="text-xs text-muted-foreground">{loadError}</p>
<button
type="button"
onClick={onRetryLoad}
className="rounded-sm border border-border px-2 py-1 text-xs hover:bg-muted/60"
>
{labels.retry}
</button>
</div>
</div>
) : isEmpty ? (
renderEmpty?.() ?? <FileManagerEmpty />
) : viewMode === "grid" ? (
<FileManagerGridView
items={items}
iconSize={iconSize}
renamingId={renamingId}
validateRename={validateRename}
iconForNode={iconForNode}
enableInternalDrag={enableInternalDrag}
dropTargetId={dropTargetId}
dropInvalid={dropInvalid}
renderItem={renderItem}
onItemClick={onItemClick}
onItemDoubleClick={onItemDoubleClick}
onItemContextMenu={onItemContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onItemDragStart={onItemDragStart}
onItemDragOver={onItemDragOver}
onItemDragLeave={onItemDragLeave}
onItemDrop={onItemDrop}
onItemDragEnd={onItemDragEnd}
/>
) : (
<FileManagerListView
items={items}
rowHeight={rowHeight}
sort={sort}
onSetSort={onSetSort}
renamingId={renamingId}
validateRename={validateRename}
iconForNode={iconForNode}
enableInternalDrag={enableInternalDrag}
dropTargetId={dropTargetId}
dropInvalid={dropInvalid}
renderItem={renderItem}
labels={labels}
scrollRef={scrollRef}
virtualize={virtualize}
virtualizeThreshold={virtualizeThreshold}
onItemClick={onItemClick}
onItemDoubleClick={onItemDoubleClick}
onItemContextMenu={onItemContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onItemDragStart={onItemDragStart}
onItemDragOver={onItemDragOver}
onItemDragLeave={onItemDragLeave}
onItemDrop={onItemDrop}
onItemDragEnd={onItemDragEnd}
/>
)}
<FileManagerMarquee rect={marqueeRect} />
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-grid-view.tsx
"use client";
import type { DragEvent, MouseEvent, ReactNode } from "react";
import type {
FileManagerIconSize,
FileManagerItem,
FileManagerItemRenderArgs,
FileManagerValidateRenameArgs,
FsNode,
} from "../types";
import { FileManagerItemView } from "./file-manager-item";
const GRID_TRACK_PX: Record<FileManagerIconSize, number> = {
sm: 80,
md: 120,
lg: 180,
};
interface FileManagerGridViewProps {
items: FileManagerItem[];
iconSize: FileManagerIconSize;
renamingId: string | null;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
iconForNode?: (args: { node: FsNode }) => ReactNode;
enableInternalDrag: boolean;
dropTargetId: string | null;
dropInvalid: boolean;
renderItem?: (args: FileManagerItemRenderArgs) => ReactNode;
onItemClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemDoubleClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemContextMenu: (e: MouseEvent, item: FileManagerItem) => void;
onRenameCommit: (id: string, nextName: string) => void;
onRenameCancel: () => void;
onItemDragStart?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragOver?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragLeave?: (e: DragEvent, item: FileManagerItem) => void;
onItemDrop?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragEnd?: (e: DragEvent, item: FileManagerItem) => void;
}
export function FileManagerGridView(props: FileManagerGridViewProps) {
const {
items,
iconSize,
renamingId,
validateRename,
iconForNode,
enableInternalDrag,
dropTargetId,
dropInvalid,
renderItem,
onItemClick,
onItemDoubleClick,
onItemContextMenu,
onRenameCommit,
onRenameCancel,
onItemDragStart,
onItemDragOver,
onItemDragLeave,
onItemDrop,
onItemDragEnd,
} = props;
const trackPx = GRID_TRACK_PX[iconSize];
return (
<div
role="grid"
aria-multiselectable="true"
className="grid gap-1 p-2"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${trackPx}px, 1fr))`,
}}
>
{items.map((item) => {
const isDropTarget = dropTargetId === item.node.id;
const defaultItem = (
<FileManagerItemView
item={item}
viewMode="grid"
iconSize={iconSize}
isRenaming={renamingId === item.node.id}
validateRename={validateRename}
iconForNode={iconForNode}
enableInternalDrag={enableInternalDrag}
isDropTarget={isDropTarget}
dropInvalid={isDropTarget && dropInvalid}
onClick={onItemClick}
onDoubleClick={onItemDoubleClick}
onContextMenu={onItemContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onDragStart={onItemDragStart}
onDragOver={onItemDragOver}
onDragLeave={onItemDragLeave}
onDrop={onItemDrop}
onDragEnd={onItemDragEnd}
/>
);
// v0.1.1 (review): `role="row"` on each cell wrapper — the grid
// previously nested gridcells under unroled divs (invalid
// grid → gridcell without a row). Matches the list view's shape.
if (renderItem) {
return (
<div key={item.node.id} role="row">
{renderItem({ item, defaultItem, viewMode: "grid" })}
</div>
);
}
return (
<div key={item.node.id} role="row">
{defaultItem}
</div>
);
})}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-list-view.tsx
"use client";
import {
useEffect,
useState,
type CSSProperties,
type DragEvent,
type MouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils";
import type {
FileManagerItem,
FileManagerItemRenderArgs,
FileManagerLabels,
FileManagerSortKey,
FileManagerSortState,
FileManagerValidateRenameArgs,
FileManagerVirtualizeMode,
FsNode,
} from "../types";
import { FileManagerItemView } from "./file-manager-item";
interface FileManagerListViewProps {
items: FileManagerItem[];
rowHeight: number;
sort: FileManagerSortState;
onSetSort: (next: FileManagerSortState) => void;
renamingId: string | null;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
iconForNode?: (args: { node: FsNode }) => ReactNode;
enableInternalDrag: boolean;
dropTargetId: string | null;
dropInvalid: boolean;
renderItem?: (args: FileManagerItemRenderArgs) => ReactNode;
labels: FileManagerLabels;
/** Scroll container ref. Required for virtualization measurements. */
scrollRef: RefObject<HTMLDivElement | null>;
/** Virtualization mode: 'auto' engages above `virtualizeThreshold`. */
virtualize: FileManagerVirtualizeMode;
virtualizeThreshold: number;
// shared callbacks
onItemClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemDoubleClick: (e: MouseEvent, item: FileManagerItem) => void;
onItemContextMenu: (e: MouseEvent, item: FileManagerItem) => void;
onRenameCommit: (id: string, nextName: string) => void;
onRenameCancel: () => void;
onItemDragStart?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragOver?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragLeave?: (e: DragEvent, item: FileManagerItem) => void;
onItemDrop?: (e: DragEvent, item: FileManagerItem) => void;
onItemDragEnd?: (e: DragEvent, item: FileManagerItem) => void;
}
function HeaderCell({
active,
order,
onClick,
className,
children,
}: {
active: boolean;
order: "asc" | "desc";
onClick: () => void;
className?: string;
children: ReactNode;
}) {
return (
<button
type="button"
role="columnheader"
aria-sort={
active ? (order === "asc" ? "ascending" : "descending") : "none"
}
onClick={onClick}
className={cn(
"flex items-center gap-1 truncate text-left text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
order === "asc" ? (
<ChevronUp className="size-3" aria-hidden="true" />
) : (
<ChevronDown className="size-3" aria-hidden="true" />
)
) : (
<ChevronsUpDown className="size-3 opacity-40" aria-hidden="true" />
)}
</button>
);
}
export function FileManagerListView(props: FileManagerListViewProps) {
const {
items,
rowHeight,
sort,
onSetSort,
renamingId,
validateRename,
iconForNode,
enableInternalDrag,
dropTargetId,
dropInvalid,
renderItem,
labels,
scrollRef,
virtualize,
virtualizeThreshold,
onItemClick,
onItemDoubleClick,
onItemContextMenu,
onRenameCommit,
onRenameCancel,
onItemDragStart,
onItemDragOver,
onItemDragLeave,
onItemDrop,
onItemDragEnd,
} = props;
// Own the virtualizer locally — keeps virtualizer state inside this
// component's scope so React Compiler doesn't strand stale measurements
// across cross-component prop boundaries.
const virtualizationActive =
virtualize === "always" ||
(virtualize === "auto" && items.length >= virtualizeThreshold);
// Sync the scroll element ref into local state so `useVirtualizer` picks it
// up on the second render (refs alone don't trigger re-render, which leaves
// the virtualizer with a null scroll element and zero measured viewport).
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(
() => scrollRef.current,
);
useEffect(() => {
if (scrollRef.current && scrollRef.current !== scrollEl) {
setScrollEl(scrollRef.current);
}
}, [scrollRef, scrollEl]);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: items.length,
getScrollElement: () => scrollEl,
estimateSize: () => rowHeight,
overscan: 8,
enabled: virtualizationActive,
});
const toggleSort = (key: FileManagerSortKey) => {
if (sort.key === key) {
onSetSort({ key, order: sort.order === "asc" ? "desc" : "asc" });
} else {
onSetSort({ key, order: "asc" });
}
};
const headerRow = (
<div
role="row"
className="sticky top-0 z-10 flex h-8 items-center gap-2 border-b border-border/60 bg-card/90 px-2 backdrop-blur-sm"
>
<span aria-hidden="true" className="size-4 shrink-0" />
<HeaderCell
active={sort.key === "name"}
order={sort.order}
onClick={() => toggleSort("name")}
className="flex-1"
>
{labels.columnName}
</HeaderCell>
<HeaderCell
active={sort.key === "modified"}
order={sort.order}
onClick={() => toggleSort("modified")}
className="hidden w-32 shrink-0 sm:flex"
>
{labels.columnModified}
</HeaderCell>
<HeaderCell
active={sort.key === "size"}
order={sort.order}
onClick={() => toggleSort("size")}
className="hidden w-20 shrink-0 justify-end sm:flex"
>
{labels.columnSize}
</HeaderCell>
<HeaderCell
active={sort.key === "type"}
order={sort.order}
onClick={() => toggleSort("type")}
className="hidden w-16 shrink-0 md:flex"
>
{labels.columnType}
</HeaderCell>
</div>
);
const buildRow = (item: FileManagerItem, outerStyle?: CSSProperties) => {
const isDropTarget = dropTargetId === item.node.id;
const defaultItem = (
<FileManagerItemView
item={item}
viewMode="list"
iconSize="sm"
isRenaming={renamingId === item.node.id}
validateRename={validateRename}
iconForNode={iconForNode}
enableInternalDrag={enableInternalDrag}
isDropTarget={isDropTarget}
dropInvalid={isDropTarget && dropInvalid}
onClick={onItemClick}
onDoubleClick={onItemDoubleClick}
onContextMenu={onItemContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onDragStart={onItemDragStart}
onDragOver={onItemDragOver}
onDragLeave={onItemDragLeave}
onDrop={onItemDrop}
onDragEnd={onItemDragEnd}
outerStyle={
outerStyle
? { ...outerStyle, height: `${rowHeight}px` }
: { height: `${rowHeight}px` }
}
/>
);
if (renderItem) {
return (
<div
key={item.node.id}
style={outerStyle}
role="row"
>
{renderItem({ item, defaultItem, viewMode: "list" })}
</div>
);
}
return (
<div
key={item.node.id}
role="row"
>
{defaultItem}
</div>
);
};
if (virtualizationActive) {
const totalHeight = virtualizer.getTotalSize();
const virtualItems = virtualizer.getVirtualItems();
return (
<div role="grid" aria-multiselectable="true">
{headerRow}
<div style={{ height: `${totalHeight}px`, position: "relative" }}>
{virtualItems.map((vi) => {
const item = items[vi.index];
if (!item) return null;
const outerStyle: CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vi.start}px)`,
};
return buildRow(item, outerStyle);
})}
</div>
</div>
);
}
return (
<div role="grid" aria-multiselectable="true">
{headerRow}
<div className="flex flex-col">
{items.map((item) => buildRow(item))}
</div>
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-item.tsx
"use client";
import type {
CSSProperties,
DragEvent,
MouseEvent,
ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import type {
FileManagerIconSize,
FileManagerItem,
FileManagerValidateRenameArgs,
FileManagerViewMode,
FsNode,
} from "../types";
import { defaultFileIcon, defaultFolderIcon } from "../lib/icons";
import { formatBytes, formatDate, formatKind } from "../lib/format";
import { getNodeExtension } from "../lib/icons";
import { FileManagerRenameInput } from "./file-manager-rename-input";
const ICON_SIZE_PX: Record<FileManagerIconSize, number> = {
sm: 32,
md: 48,
lg: 72,
};
const GRID_TRACK_PX: Record<FileManagerIconSize, number> = {
sm: 80,
md: 120,
lg: 180,
};
interface FileManagerItemViewProps {
item: FileManagerItem;
viewMode: FileManagerViewMode;
iconSize: FileManagerIconSize;
isRenaming: boolean;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
iconForNode?: (args: { node: FsNode }) => ReactNode;
enableInternalDrag: boolean;
/** Drop hover indicator state (only set on the hovered drop target). */
isDropTarget?: boolean;
dropInvalid?: boolean;
// shared callbacks
onClick: (e: MouseEvent, item: FileManagerItem) => void;
onDoubleClick: (e: MouseEvent, item: FileManagerItem) => void;
onContextMenu: (e: MouseEvent, item: FileManagerItem) => void;
onRenameCommit: (id: string, nextName: string) => void;
onRenameCancel: () => void;
onDragStart?: (e: DragEvent, item: FileManagerItem) => void;
onDragOver?: (e: DragEvent, item: FileManagerItem) => void;
onDragLeave?: (e: DragEvent, item: FileManagerItem) => void;
onDrop?: (e: DragEvent, item: FileManagerItem) => void;
onDragEnd?: (e: DragEvent, item: FileManagerItem) => void;
/** Used by virtualized list mode to position rows absolutely. */
outerStyle?: CSSProperties;
}
export function FileManagerItemView(props: FileManagerItemViewProps) {
const {
item,
viewMode,
iconSize,
isRenaming,
validateRename,
iconForNode,
enableInternalDrag,
isDropTarget,
dropInvalid,
onClick,
onDoubleClick,
onContextMenu,
onRenameCommit,
onRenameCancel,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
outerStyle,
} = props;
const { node, selected, focused, cut } = item;
const isFolder = node.type === "folder";
const ext = getNodeExtension(node);
const baseIcon = node.icon
? node.icon
: iconForNode
? iconForNode({ node })
: isFolder
? defaultFolderIcon(false, "size-full")
: defaultFileIcon(node, "size-full");
const sharedProps = {
"data-item-id": node.id,
role: "gridcell" as const,
tabIndex: focused ? 0 : -1,
"aria-selected": selected,
draggable: enableInternalDrag && !isRenaming,
onClick: (e: MouseEvent) => onClick(e, item),
onDoubleClick: (e: MouseEvent) => onDoubleClick(e, item),
onContextMenu: (e: MouseEvent) => onContextMenu(e, item),
onDragStart: (e: DragEvent) =>
enableInternalDrag && onDragStart && onDragStart(e, item),
onDragOver: (e: DragEvent) => onDragOver?.(e, item),
onDragLeave: (e: DragEvent) => onDragLeave?.(e, item),
onDrop: (e: DragEvent) => onDrop?.(e, item),
onDragEnd: (e: DragEvent) => onDragEnd?.(e, item),
};
if (viewMode === "grid") {
const trackPx = GRID_TRACK_PX[iconSize];
const iconPx = ICON_SIZE_PX[iconSize];
return (
<div
{...sharedProps}
style={outerStyle}
className={cn(
"group relative flex cursor-pointer select-none flex-col items-center gap-1.5 rounded-md p-2 outline-none transition-colors",
selected
? "bg-secondary text-secondary-foreground"
: "hover:bg-muted/60",
focused &&
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
isDropTarget &&
!dropInvalid &&
"ring-2 ring-primary",
isDropTarget && dropInvalid && "ring-2 ring-destructive",
cut && "opacity-50",
)}
>
<div
aria-hidden="true"
className={cn(
"flex items-center justify-center opacity-80 transition-opacity",
(selected || focused) && "opacity-100",
"group-hover:opacity-100",
isFolder
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
selected && "text-secondary-foreground",
)}
style={{ width: iconPx, height: iconPx }}
>
{baseIcon}
</div>
{isRenaming ? (
<div style={{ width: trackPx - 8 }}>
<FileManagerRenameInput
node={node}
validateRename={validateRename}
onCommit={(next) => onRenameCommit(node.id, next)}
onCancel={onRenameCancel}
/>
</div>
) : (
<span
className="line-clamp-2 max-w-full text-center text-xs"
title={node.name}
style={{ maxWidth: trackPx - 4 }}
>
{node.name}
</span>
)}
</div>
);
}
// List mode
return (
<div
{...sharedProps}
style={outerStyle}
className={cn(
"group flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 text-sm outline-none transition-colors",
selected
? "bg-secondary text-secondary-foreground"
: "hover:bg-muted/60",
focused &&
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
isDropTarget &&
!dropInvalid &&
"ring-2 ring-primary",
isDropTarget && dropInvalid && "ring-2 ring-destructive",
cut && "opacity-50",
)}
>
<span
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center opacity-80 transition-opacity group-hover:opacity-100",
(selected || focused) && "opacity-100",
isFolder
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
selected && "text-secondary-foreground",
)}
>
{baseIcon}
</span>
<span className="min-w-0 flex-1 truncate" title={node.name}>
{isRenaming ? (
<FileManagerRenameInput
node={node}
validateRename={validateRename}
onCommit={(next) => onRenameCommit(node.id, next)}
onCancel={onRenameCancel}
/>
) : (
node.name
)}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground sm:block">
{formatDate(node.modifiedAt)}
</span>
<span className="hidden w-20 shrink-0 text-right font-mono text-xs text-muted-foreground sm:block">
{isFolder ? "—" : formatBytes(node.size ?? 0)}
</span>
<span className="hidden w-16 shrink-0 text-xs text-muted-foreground md:block">
{formatKind(ext, node.type)}
</span>
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-rename-input.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { FsNode, FileManagerValidateRenameArgs } from "../types";
interface FileManagerRenameInputProps {
node: FsNode;
onCommit: (nextName: string) => void;
onCancel: () => void;
validateRename?: (args: FileManagerValidateRenameArgs) => string | null;
className?: string;
}
export function FileManagerRenameInput(props: FileManagerRenameInputProps) {
const { node, onCommit, onCancel, validateRename, className } = props;
const [value, setValue] = useState(node.name);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
if (node.type === "folder") {
input.select();
} else {
const dot = node.name.lastIndexOf(".");
if (dot > 0) input.setSelectionRange(0, dot);
else input.select();
}
}, [node.type, node.name]);
const tryCommit = () => {
const trimmed = value.trim();
if (trimmed === "") {
setError("Name cannot be empty");
return;
}
if (trimmed === node.name) {
onCancel();
return;
}
if (validateRename) {
const message = validateRename({ node, nextName: trimmed });
if (message) {
setError(message);
return;
}
}
onCommit(trimmed);
};
return (
<div className={cn("relative flex flex-1 min-w-0 items-center", className)}>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
tryCommit();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCancel();
} else {
e.stopPropagation();
}
}}
onBlur={() => tryCommit()}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
aria-label={`Rename ${node.name}`}
aria-invalid={error ? true : undefined}
className={cn(
"h-6 min-w-0 flex-1 rounded-sm border bg-background px-1 text-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
error
? "border-destructive ring-1 ring-destructive"
: "border-border",
)}
/>
{error ? (
<span
role="alert"
className="pointer-events-none absolute left-0 top-full z-20 mt-0.5 rounded-sm bg-destructive px-1.5 py-0.5 text-xs text-destructive-foreground shadow-sm"
>
{error}
</span>
) : null}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-context-menu.tsx
"use client";
import { useMemo, useState, type MouseEvent, type ReactNode } from "react";
import {
ClipboardCopy,
ClipboardPaste,
FilePlus,
FolderPlus,
Pencil,
RefreshCw,
Scissors,
Trash2,
} from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import type {
FileManagerActions,
FileManagerContextMenuContext,
FileManagerContextMenuItem,
FileManagerLabels,
FileManagerState,
FsNode,
} from "../types";
interface FileManagerContextMenuProps {
state: FileManagerState;
actions: FileManagerActions;
enabled: boolean;
labels: FileManagerLabels;
gates: {
open: boolean;
newFile: boolean;
newFolder: boolean;
cut: boolean;
copy: boolean;
paste: boolean;
rename: boolean;
delete: boolean;
refresh: boolean;
};
wired: {
onOpen: boolean;
onCreate: boolean;
onRename: boolean;
onDelete: boolean;
onMove: boolean;
onPaste: boolean;
onRefresh: boolean;
};
nodeIndex: ReadonlyMap<string, FsNode>;
renderContextMenu?: (ctx: FileManagerContextMenuContext) => ReactNode;
children: ReactNode;
}
export function FileManagerContextMenu(props: FileManagerContextMenuProps) {
const {
state,
actions,
enabled,
labels,
gates,
wired,
nodeIndex,
renderContextMenu,
children,
} = props;
const [target, setTarget] = useState<{
node: FsNode | null;
position: { x: number; y: number };
} | null>(null);
const captureTarget = (e: MouseEvent) => {
const el = e.target as HTMLElement | null;
const itemEl = el?.closest("[data-item-id]") as HTMLElement | null;
const id = itemEl?.getAttribute("data-item-id") ?? null;
const node = id ? (nodeIndex.get(id) ?? null) : null;
setTarget({ node, position: { x: e.clientX, y: e.clientY } });
};
const defaultActions = useMemo<FileManagerContextMenuItem[]>(() => {
if (!target) return [];
const node = target.node;
const list: FileManagerContextMenuItem[] = [];
const hasClipboard = state.clipboard.kind !== null;
const ids =
node && state.selectedIds.has(node.id)
? Array.from(state.selectedIds)
: node
? [node.id]
: [];
if (gates.open && wired.onOpen && node && node.type === "file") {
list.push({
id: "open",
label: labels.contextOpen,
icon: <FilePlus className="size-3.5" />,
onSelect: () => actions.triggerOpen(node.id),
});
}
if (gates.newFile && wired.onCreate) {
list.push({
id: "new-file",
label: labels.contextNewFile,
icon: <FilePlus className="size-3.5" />,
onSelect: () => actions.triggerCreate("file"),
});
}
if (gates.newFolder && wired.onCreate) {
list.push({
id: "new-folder",
label: labels.contextNewFolder,
icon: <FolderPlus className="size-3.5" />,
onSelect: () => actions.triggerCreate("folder"),
});
}
if (gates.cut && node && ids.length > 0) {
list.push({
id: "cut",
label: labels.contextCut,
icon: <Scissors className="size-3.5" />,
onSelect: () => actions.cut(ids),
});
}
if (gates.copy && node && ids.length > 0) {
list.push({
id: "copy",
label: labels.contextCopy,
icon: <ClipboardCopy className="size-3.5" />,
onSelect: () => actions.copy(ids),
});
}
if (gates.paste && wired.onPaste) {
list.push({
id: "paste",
label: labels.contextPaste,
icon: <ClipboardPaste className="size-3.5" />,
disabled: !hasClipboard,
onSelect: () => actions.paste(),
});
}
if (gates.rename && wired.onRename && node) {
list.push({
id: "rename",
label: labels.contextRename,
icon: <Pencil className="size-3.5" />,
onSelect: () => actions.startRename(node.id),
});
}
if (gates.refresh && wired.onRefresh) {
list.push({
id: "refresh",
label: labels.contextRefresh,
icon: <RefreshCw className="size-3.5" />,
onSelect: () => actions.refresh(state.currentFolderId),
});
}
if (gates.delete && wired.onDelete && node && ids.length > 0) {
list.push({
id: "delete",
label: labels.contextDelete,
icon: <Trash2 className="size-3.5" />,
destructive: true,
onSelect: () => actions.triggerDelete(ids),
});
}
return list;
}, [target, state, gates, wired, labels, actions]);
if (!enabled) {
return <>{children}</>;
}
const ctx: FileManagerContextMenuContext = {
state,
actions,
node: target?.node ?? null,
defaultActions,
position: target?.position ?? { x: 0, y: 0 },
labels,
};
return (
<ContextMenu>
{/* F-cross-13: no `asChild` — Base UI triggers reject it. `display: contents`
keeps the trigger element out of layout (the wrapped div already used it);
right-clicks bubble up from children. */}
<ContextMenuTrigger className="contents" onContextMenu={captureTarget}>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderContextMenu ? (
renderContextMenu(ctx)
) : defaultActions.length === 0 ? (
<ContextMenuItem disabled>{labels.emptyTitle}</ContextMenuItem>
) : (
<DefaultItems items={defaultActions} />
)}
</ContextMenuContent>
</ContextMenu>
);
}
function DefaultItems({ items }: { items: FileManagerContextMenuItem[] }) {
const out: ReactNode[] = [];
let lastSep = false;
items.forEach((item, i) => {
if (item.destructive && i > 0 && !lastSep) {
out.push(<ContextMenuSeparator key={`sep-${i}`} />);
lastSep = true;
} else {
lastSep = false;
}
out.push(
<ContextMenuItem
key={item.id}
disabled={item.disabled}
variant={item.destructive ? "destructive" : "default"}
onSelect={() => item.onSelect()}
>
{item.icon}
{item.label}
</ContextMenuItem>,
);
});
return <>{out}</>;
}
src/registry/components/navigation/file-manager/parts/file-manager-status-bar.tsx
"use client";
import { useFileManager } from "../hooks/use-file-manager-context";
import { formatBytes } from "../lib/format";
export function FileManagerStatusBar() {
const { state, visibleCount, totalSize, labels } = useFileManager();
const selectedCount = state.selectedIds.size;
return (
<div
role="status"
aria-live="polite"
className="flex h-7 shrink-0 items-center gap-3 border-t border-border/60 bg-card/40 px-3 text-xs text-muted-foreground"
>
<span>
{visibleCount} {labels.itemCount}
</span>
{selectedCount > 0 ? (
<span>
{selectedCount} {labels.itemCountSelected}
</span>
) : null}
{totalSize > 0 ? (
<span className="ml-auto font-mono">
{formatBytes(totalSize)} {labels.totalSize}
</span>
) : null}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-empty.tsx
"use client";
import { FilePlus, FolderPlus, Inbox } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerEmpty() {
const { actions, showNewFile, showNewFolder, labels } = useFileManager();
return (
<div
className="flex h-full flex-col items-center justify-center gap-3 px-4 py-10 text-center text-sm text-muted-foreground"
role="status"
>
<Inbox aria-hidden="true" className="size-8 opacity-60" />
<p>{labels.emptyTitle}</p>
{(showNewFile || showNewFolder) && (
<div className="flex gap-2">
{showNewFile ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => actions.triggerCreate("file")}
>
<FilePlus className="size-3.5" aria-hidden="true" />
{labels.newFile}
</Button>
) : null}
{showNewFolder ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => actions.triggerCreate("folder")}
>
<FolderPlus className="size-3.5" aria-hidden="true" />
{labels.newFolder}
</Button>
) : null}
</div>
)}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-loading.tsx
"use client";
import { useFileManager } from "../hooks/use-file-manager-context";
export function FileManagerLoading() {
const { labels } = useFileManager();
return (
<div
className="grid gap-2 p-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
}}
role="status"
aria-label={labels.loading}
>
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<div key={i} className="flex flex-col items-center gap-2 p-2">
<div
className="h-12 w-12 animate-pulse rounded-md bg-muted"
style={{ animationDelay: `${i * 80}ms` }}
/>
<div
className="h-3 w-3/4 animate-pulse rounded-sm bg-muted"
style={{ animationDelay: `${i * 80 + 40}ms` }}
/>
</div>
))}
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-drag-overlay.tsx
"use client";
import { FileUp } from "lucide-react";
import type { FileManagerLabels } from "../types";
interface FileManagerDragOverlayProps {
visible: boolean;
labels: FileManagerLabels;
}
export function FileManagerDragOverlay(props: FileManagerDragOverlayProps) {
const { visible, labels } = props;
if (!visible) return null;
return (
<div
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-md border-2 border-dashed border-primary/70 bg-background/85 backdrop-blur-[2px]"
role="presentation"
>
<div className="flex flex-col items-center gap-2 text-sm font-medium text-foreground">
<FileUp className="size-6 text-primary" aria-hidden="true" />
<span>{labels.externalDropOverlay}</span>
</div>
</div>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-delete-confirm.tsx
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { FsNode, FileManagerLabels } from "../types";
interface FileManagerDeleteConfirmProps {
open: boolean;
ids: string[];
nodes: FsNode[];
labels: FileManagerLabels;
onConfirm: () => void;
onCancel: () => void;
}
export function FileManagerDeleteConfirm(
props: FileManagerDeleteConfirmProps,
) {
const { open, ids, nodes, labels, onConfirm, onCancel } = props;
const count = ids.length;
const first = nodes[0]?.name ?? "";
const title =
count === 1
? `Delete "${first}"?`
: labels.deleteConfirmTitle.replace("items", `${count} items`);
const description =
count === 1
? labels.deleteConfirmDescription
: `Delete "${first}" and ${count - 1} other${count - 1 > 1 ? "s" : ""}? ${labels.deleteConfirmDescription}`;
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
{labels.deleteConfirmCancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/40"
>
{labels.deleteConfirmAction}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
src/registry/components/navigation/file-manager/parts/file-manager-marquee.tsx
"use client";
import type { MarqueeRect } from "../hooks/use-marquee";
interface FileManagerMarqueeProps {
rect: MarqueeRect | null;
}
/**
* Visual rectangle drawn during marquee selection. Positioned absolutely
* relative to the scroll container; consumer must place it inside a
* `position: relative` ancestor (the content pane handles this).
*/
export function FileManagerMarquee(props: FileManagerMarqueeProps) {
const { rect } = props;
if (!rect) return null;
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 rounded-sm border border-primary bg-primary/15"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}}
/>
);
}