shadimage
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 · light
iconslucide-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,
      }}
    />
  );
}

More from ilinxa

Browse all components in this library →