File Explorer

Compose a local file manager from Files, Toolbar, context menus, upload, breadcrumbs, and details.

Files
18
Contracts
Correspondence
Court Filings
Depositions
Discovery
Research
Board Minutes.docx
Case Budget.xlsx
Client Contract.docx
Expense Receipts.zip
Global Marketin…FY2025.pdf
Headshots Portfolio.png
Legal Memo.txt
Platform Migrat… v2.3.docx
Q4 Financial Report.pdf
Settlement Strategy.pptx
Sprint Tasks.csv
Team Building Event.jpg

This example shows the consumer-owned shape for a full file manager. Cubitt provides Files selection, inline rename, upload input wiring, details display, and primitive menus. The app owns the directory model, navigation, persistence, permissions, action labels, and the behavior behind each menu item.

apps/docs/src/components/examples/files-explorer/file-explorer-example.tsx
import { useMemo, useState } from "react";
import {
  Files,
  type FilesItem,
  type FilesMoveEvent,
  type FilesUploadEntry,
  type FilesViewMode,
  useFilesContext,
} from "@tilt-legal/cubitt-components/files";
import { initialExplorerItems, ROOT_DIRECTORY_ID } from "./data";
import { FileExplorerDetailsPanel } from "./details-panel";
import { FileExplorerSurface } from "./file-surface";
import { FileExplorerToolbar } from "./toolbar";
import type {
  ExplorerActionHandlers,
  ExplorerItem,
  ExplorerSource,
} from "./types";
import {
  canMoveExplorerItems,
  createFolderItem,
  createUploadedItems,
  downloadFakeFile,
  getChildren,
  getDirectoryDetailsItem,
  getDirectoryPath,
  getMoveDestinationDirectoryId,
  getSelectedItems,
  moveExplorerItems,
  openFakeFile,
  removeItemsAndDescendants,
} from "./utils";

function FileExplorerInner(props: {
  currentDirectoryId: string;
  detailsOpen: boolean;
  items: ExplorerItem[];
  onCurrentDirectoryChange: (directoryId: string) => void;
  onDetailsOpenChange: (open: boolean) => void;
  onItemsChange: (items: ExplorerItem[]) => void;
  onSelectedIdsChange: (ids: string[]) => void;
  onViewChange: (view: FilesViewMode) => void;
  view: FilesViewMode;
}) {
  const filesContext = useFilesContext();
  const visibleItems = useMemo(
    () => getChildren(props.items, props.currentDirectoryId),
    [props.currentDirectoryId, props.items],
  );
  const selectedItems = useMemo(
    () => getSelectedItems(props.items, filesContext.selectedIds),
    [filesContext.selectedIds, props.items],
  );
  const currentPath = useMemo(
    () => getDirectoryPath(props.items, props.currentDirectoryId),
    [props.currentDirectoryId, props.items],
  );
  const detailsItems =
    selectedItems.length > 0
      ? selectedItems
      : [getDirectoryDetailsItem(props.items, props.currentDirectoryId)];

  function navigateToDirectory(directoryId: string) {
    props.onCurrentDirectoryChange(directoryId);
    props.onSelectedIdsChange([]);
  }

  function createFolder() {
    const folder = createFolderItem(props.currentDirectoryId);
    props.onItemsChange([...props.items, folder]);
    props.onSelectedIdsChange([folder.id]);
    filesContext.startRename(folder);
  }

  const actionHandlers: ExplorerActionHandlers = {
    onArchive: (items) => {
      props.onItemsChange(removeItemsAndDescendants(props.items, items));
      props.onSelectedIdsChange([]);
    },
    onCreateFolder: createFolder,
    onDownload: (items) => {
      items.filter((item) => item.kind === "file").forEach(downloadFakeFile);
    },
    onOpen: (item) => {
      if (item.kind === "folder") {
        navigateToDirectory(item.id);
        return;
      }

      openFakeFile(item);
    },
    onRename: filesContext.startRename,
    onViewDetails: () => props.onDetailsOpenChange(true),
  };

  return (
    <>
      <div className="sticky top-0 z-20 bg-background pb-4">
        <FileExplorerToolbar
          actionHandlers={actionHandlers}
          currentPath={currentPath}
          itemCount={visibleItems.length}
          items={props.items}
          onFilesAccepted={(entries: FilesUploadEntry[]) => {
            const uploadedItems = createUploadedItems(
              entries,
              props.currentDirectoryId,
            );
            props.onItemsChange([...props.items, ...uploadedItems]);
            props.onSelectedIdsChange(uploadedItems.map((item) => item.id));
          }}
          onNavigate={navigateToDirectory}
          onViewChange={props.onViewChange}
          view={props.view}
        />
      </div>
      <div className="flex min-h-[calc(100%-4rem)]">
        <Files.Content
          className="min-h-[30rem]"
          moveDestinationId={props.currentDirectoryId}
        >
          <FileExplorerSurface
            actionHandlers={actionHandlers}
            items={visibleItems}
            view={props.view}
          />
        </Files.Content>
        <FileExplorerDetailsPanel
          items={detailsItems}
          onClose={() => props.onDetailsOpenChange(false)}
          open={props.detailsOpen}
        />
      </div>
    </>
  );
}

export function FilesExplorerConsumerExample() {
  const [items, setItems] = useState<ExplorerItem[]>(initialExplorerItems);
  const [currentDirectoryId, setCurrentDirectoryId] =
    useState(ROOT_DIRECTORY_ID);
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [view, setView] = useState<FilesViewMode>("grid");
  const [detailsOpen, setDetailsOpen] = useState(false);

  function renameItem(item: FilesItem<ExplorerSource>, name: string) {
    setItems((currentItems) =>
      currentItems.map((currentItem) =>
        currentItem.id === item.id ? { ...currentItem, name } : currentItem,
      ),
    );
  }

  const currentItem = useMemo(
    () => getDirectoryDetailsItem(items, currentDirectoryId),
    [currentDirectoryId, items],
  );

  function moveItems(event: FilesMoveEvent<ExplorerSource>) {
    const destinationId = getMoveDestinationDirectoryId(event);

    setItems((currentItems) =>
      moveExplorerItems(currentItems, event.items, destinationId),
    );
    setSelectedIds(event.items.map((item) => item.id));
  }

  return (
    <Files
      canMove={(event) => canMoveExplorerItems(items, event)}
      canRename={(item) => item.id !== ROOT_DIRECTORY_ID}
      className="h-[560px] w-full max-w-6xl overflow-y-auto"
      detailsItem={currentItem}
      items={items}
      onMove={moveItems}
      onRename={renameItem}
      onSelectedIdsChange={setSelectedIds}
      selectedIds={selectedIds}
      selectionBehavior="replace"
      selectionMode="multiple"
    >
      <FileExplorerInner
        currentDirectoryId={currentDirectoryId}
        detailsOpen={detailsOpen}
        items={items}
        onCurrentDirectoryChange={setCurrentDirectoryId}
        onDetailsOpenChange={setDetailsOpen}
        onItemsChange={setItems}
        onSelectedIdsChange={setSelectedIds}
        onViewChange={setView}
        view={view}
      />
    </Files>
  );
}
apps/docs/src/components/examples/files-explorer/toolbar.tsx
import { Fragment } from "react";
import {
  BreadcrumbIcon,
  BreadcrumbItem,
  BreadcrumbLabel,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@tilt-legal/cubitt-components/breadcrumb";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
  Menu,
  MenuContent,
  MenuTrigger,
} from "@tilt-legal/cubitt-components/menu";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@tilt-legal/cubitt-components/toggle-group";
import { Toolbar } from "@tilt-legal/cubitt-components/toolbar";
import {
  Dots,
  Grid,
  Menu as MenuIcon,
} from "@tilt-legal/cubitt-icons/ui/outline";
import {
  Files,
  type FilesUploadEntry,
  type FilesViewMode,
  useFilesContext,
} from "@tilt-legal/cubitt-components/files";
import { FileExplorerActionMenuItems } from "./file-actions";
import { getSelectedItems } from "./utils";
import type {
  ExplorerActionHandlers,
  ExplorerFolder,
  ExplorerItem,
} from "./types";

export function FileExplorerToolbar({
  actionHandlers,
  currentPath,
  itemCount,
  items,
  onFilesAccepted,
  onNavigate,
  onViewChange,
  view,
}: {
  actionHandlers: ExplorerActionHandlers;
  currentPath: ExplorerFolder[];
  itemCount: number;
  items: ExplorerItem[];
  onFilesAccepted: (entries: FilesUploadEntry[]) => void;
  onNavigate: (directoryId: string) => void;
  onViewChange: (view: FilesViewMode) => void;
  view: FilesViewMode;
}) {
  const filesContext = useFilesContext();
  const selectedItems = getSelectedItems(items, filesContext.selectedIds);

  return (
    <Toolbar size="md">
      <Toolbar.Heading className="min-w-0">
        {currentPath.length === 1 ? (
          <>
            <Toolbar.Title>Files</Toolbar.Title>
            <Toolbar.Count>{itemCount}</Toolbar.Count>
          </>
        ) : (
          <Toolbar.Breadcrumb className="@container w-full">
            <BreadcrumbList>
              <BreadcrumbItem className="shrink-0">
                <BreadcrumbLink
                  onClick={() => onNavigate(currentPath[0]?.id ?? "root")}
                  render={<button type="button" />}
                >
                  <BreadcrumbLabel className="shrink-0">Files</BreadcrumbLabel>
                </BreadcrumbLink>
              </BreadcrumbItem>
              {currentPath.slice(1).map((directory, index, path) => {
                const isCurrent = index === path.length - 1;

                return (
                  <Fragment key={directory.id}>
                    <BreadcrumbSeparator>/</BreadcrumbSeparator>
                    <BreadcrumbItem
                      className={isCurrent ? "min-w-0" : "shrink-0"}
                    >
                      {isCurrent ? (
                        <BreadcrumbPage className="min-w-0">
                          <BreadcrumbIcon>
                            <Files.FolderIcon />
                          </BreadcrumbIcon>
                          <BreadcrumbLabel>{directory.name}</BreadcrumbLabel>
                        </BreadcrumbPage>
                      ) : (
                        <BreadcrumbLink
                          onClick={() => onNavigate(directory.id)}
                          render={<button type="button" />}
                        >
                          <BreadcrumbIcon>
                            <Files.FolderIcon />
                          </BreadcrumbIcon>
                          <BreadcrumbLabel className="shrink-0">
                            {directory.name}
                          </BreadcrumbLabel>
                        </BreadcrumbLink>
                      )}
                    </BreadcrumbItem>
                  </Fragment>
                );
              })}
            </BreadcrumbList>
          </Toolbar.Breadcrumb>
        )}
      </Toolbar.Heading>
      <Toolbar.Actions>
        <Menu>
          <Button
            aria-label="File actions"
            mode="icon"
            render={<MenuTrigger />}
            variant="secondary"
          >
            <Dots />
          </Button>
          <MenuContent align="end">
            <FileExplorerActionMenuItems
              handlers={actionHandlers}
              kind="toolbar"
              showCreateFolder
              targetItems={selectedItems}
            />
          </MenuContent>
        </Menu>
        <ToggleGroup
          aria-label="View"
          groupVariant="segmented"
          multiple={false}
          onValueChange={(nextValue) => {
            if (nextValue === "grid" || nextValue === "list") {
              onViewChange(nextValue);
            }
          }}
          value={view}
        >
          <ToggleGroupItem aria-label="Grid view" mode="icon" value="grid">
            <Grid />
          </ToggleGroupItem>
          <ToggleGroupItem aria-label="List view" mode="icon" value="list">
            <MenuIcon />
          </ToggleGroupItem>
        </ToggleGroup>
        <Toolbar.Separator />
        <Files.UploadButton multiple onFilesAccepted={onFilesAccepted} />
      </Toolbar.Actions>
    </Toolbar>
  );
}
apps/docs/src/components/examples/files-explorer/file-surface.tsx
import {
  Files,
  type FilesViewMode,
  useFilesContextMenu,
} from "@tilt-legal/cubitt-components/files";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuTrigger,
} from "@tilt-legal/cubitt-components/context-menu";
import { FileExplorerActionMenuItems } from "./file-actions";
import type { ExplorerActionHandlers, ExplorerItem } from "./types";

export function FileExplorerSurface({
  actionHandlers,
  items,
  view,
}: {
  actionHandlers: ExplorerActionHandlers;
  items: ExplorerItem[];
  view: FilesViewMode;
}) {
  const contextMenu = useFilesContextMenu(items);

  return (
    <ContextMenu {...contextMenu.getRootProps()}>
      <ContextMenuTrigger
        render={(triggerProps) =>
          view === "grid" ? (
            <Files.Grid
              className="min-h-full"
              {...contextMenu.getTriggerProps(triggerProps)}
            >
              {items.map((item) => (
                <Files.Item
                  item={item}
                  key={item.id}
                  onOpen={actionHandlers.onOpen}
                  {...contextMenu.getItemProps(item)}
                />
              ))}
            </Files.Grid>
          ) : (
            <Files.List
              className="min-h-full"
              {...contextMenu.getTriggerProps(triggerProps)}
            >
              {items.map((item) => (
                <Files.Item
                  item={item}
                  key={item.id}
                  onOpen={actionHandlers.onOpen}
                  {...contextMenu.getItemProps(item)}
                />
              ))}
            </Files.List>
          )
        }
      />
      <ContextMenuContent>
        <FileExplorerActionMenuItems
          handlers={actionHandlers}
          kind="context"
          showCreateFolder={contextMenu.targetItems.length === 0}
          targetItems={contextMenu.targetItems}
        />
      </ContextMenuContent>
    </ContextMenu>
  );
}
apps/docs/src/components/examples/files-explorer/file-actions.tsx
import type { ReactNode } from "react";
import {
  CircleInfo,
  Download4,
  FolderPlus,
  Pen2,
  Trash,
} from "@tilt-legal/cubitt-icons/ui/outline";
import {
  ContextMenuItem,
  ContextMenuSeparator,
} from "@tilt-legal/cubitt-components/context-menu";
import {
  MenuItem,
  MenuSeparator,
} from "@tilt-legal/cubitt-components/menu";
import { formatCount } from "./utils";
import type {
  ExplorerActionHandlers,
  ExplorerActionMenuKind,
  ExplorerItem,
} from "./types";

function ActionMenuItem({
  children,
  kind,
  onClick,
  variant,
}: {
  children: ReactNode;
  kind: ExplorerActionMenuKind;
  onClick?: () => void;
  variant?: "destructive";
}) {
  if (kind === "context") {
    return (
      <ContextMenuItem onClick={onClick} variant={variant}>
        {children}
      </ContextMenuItem>
    );
  }

  return (
    <MenuItem onClick={onClick} variant={variant}>
      {children}
    </MenuItem>
  );
}

function ActionMenuSeparator({ kind }: { kind: ExplorerActionMenuKind }) {
  return kind === "context" ? <ContextMenuSeparator /> : <MenuSeparator />;
}

function CreateFolderMenuItem({
  handlers,
  kind,
}: {
  handlers: ExplorerActionHandlers;
  kind: ExplorerActionMenuKind;
}) {
  return (
    <ActionMenuItem kind={kind} onClick={handlers.onCreateFolder}>
      <FolderPlus />
      New folder
    </ActionMenuItem>
  );
}

function ViewDetailsMenuItem({
  handlers,
  kind,
}: {
  handlers: ExplorerActionHandlers;
  kind: ExplorerActionMenuKind;
}) {
  return (
    <ActionMenuItem kind={kind} onClick={handlers.onViewDetails}>
      <CircleInfo />
      View details
    </ActionMenuItem>
  );
}

export function FileExplorerActionMenuItems({
  handlers,
  kind,
  showCreateFolder = false,
  targetItems,
}: {
  handlers: ExplorerActionHandlers;
  kind: ExplorerActionMenuKind;
  showCreateFolder?: boolean;
  targetItems: ExplorerItem[];
}) {
  if (targetItems.length === 0) {
    return (
      <>
        <CreateFolderMenuItem handlers={handlers} kind={kind} />
        <ViewDetailsMenuItem handlers={handlers} kind={kind} />
      </>
    );
  }

  return (
    <>
      {showCreateFolder ? (
        <>
          <CreateFolderMenuItem handlers={handlers} kind={kind} />
          <ViewDetailsMenuItem handlers={handlers} kind={kind} />
          <ActionMenuSeparator kind={kind} />
        </>
      ) : null}
      <TargetActionMenuItems
        handlers={handlers}
        kind={kind}
        showViewDetails={!showCreateFolder}
        targetItems={targetItems}
      />
    </>
  );
}
apps/docs/src/components/examples/files-explorer/details-panel.tsx
import { Files } from "@tilt-legal/cubitt-components/files";
import { Button } from "@tilt-legal/cubitt-components/button";
import { Separator } from "@tilt-legal/cubitt-components/separator";
import { formatBytes } from "@tilt-legal/cubitt-components/utilities/formatters";
import { Xmark } from "@tilt-legal/cubitt-icons/ui/outline";
import type { ExplorerItem } from "./types";
import { formatCount } from "./utils";

export function FileExplorerDetailsPanel({
  items,
  onClose,
  open,
}: {
  items: ExplorerItem[];
  onClose: () => void;
  open: boolean;
}) {
  const singleItem = items.length === 1 ? items[0] : null;
  const ownerCount = new Set(
    items
      .map((item) => item.source?.owner)
      .filter((owner): owner is string => Boolean(owner)),
  ).size;
  const statusCount = new Set(
    items
      .map((item) => item.source?.status)
      .filter(
        (status): status is NonNullable<ExplorerItem["source"]>["status"] =>
          Boolean(status),
      ),
  ).size;
  const totalSizeLabel = items.every((item) => typeof item.size === "number")
    ? formatBytes(
        items.reduce((totalSize, item) => totalSize + (item.size ?? 0), 0),
      )
    : null;

  return (
    <div
      aria-hidden={!open}
      className={
        open
          ? "sticky top-16 h-[calc(560px-4rem)] w-[400px] shrink-0 overflow-hidden border-l border-border-3 opacity-100"
          : "w-0 shrink-0 overflow-hidden opacity-0"
      }
    >
      <Button
        aria-label="Close details"
        className="absolute top-1 right-1 z-10"
        mode="icon"
        onClick={onClose}
        type="button"
        variant="secondary"
      >
        <Xmark />
      </Button>
      <Files.Details className="h-full min-w-0 ps-6">
        <Files.Details.Icon />
        <Files.Details.Header>
          <Files.Details.Heading>
            <Files.Details.Name />
            <Files.Details.Type />
          </Files.Details.Heading>
          <Files.Details.EditNameButton />
        </Files.Details.Header>
        <Files.Details.Panels>
          <Files.Details.Panel label="Details" value="details">
            <Separator />
            <Files.Details.Properties>
              {singleItem?.source?.owner ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Owner
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {singleItem.source.owner}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {singleItem?.source?.status ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Status
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {singleItem.source.status}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {singleItem?.source?.updatedLabel ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Updated
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {singleItem.source.updatedLabel}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {singleItem?.sizeLabel ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Size
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {singleItem.sizeLabel}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {items.length > 1 ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Files selected
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {items.length}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {items.length > 1 && totalSizeLabel ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Total size
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {totalSizeLabel}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {items.length > 1 ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Owners
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {formatCount(ownerCount, "owner")}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
              {items.length > 1 ? (
                <Files.Details.Property>
                  <Files.Details.Property.Label>
                    Statuses
                  </Files.Details.Property.Label>
                  <Files.Details.Property.Value>
                    {formatCount(statusCount, "status")}
                  </Files.Details.Property.Value>
                </Files.Details.Property>
              ) : null}
            </Files.Details.Properties>
          </Files.Details.Panel>
        </Files.Details.Panels>
      </Files.Details>
    </div>
  );
}
apps/docs/src/components/examples/files-explorer/data.ts
import type { ExplorerFolder, ExplorerItem } from "./types";

export const ROOT_DIRECTORY_ID = "root";

export const rootDirectory: ExplorerFolder = {
  id: ROOT_DIRECTORY_ID,
  kind: "folder",
  name: "Files",
  description: "Workspace root",
  source: {
    owner: "Matter team",
    parentId: null,
    status: "Reviewed",
    updatedLabel: "Today",
  },
};

export const initialExplorerItems: ExplorerItem[] = [
  {
    id: "folder-discovery",
    kind: "folder",
    name: "Discovery",
    description: "4 items",
    source: {
      owner: "Sarah Chen",
      parentId: ROOT_DIRECTORY_ID,
      status: "Draft",
      updatedLabel: "Today",
    },
  },
  {
    id: "folder-depositions",
    kind: "folder",
    name: "Depositions",
    description: "3 items",
    source: {
      owner: "Ava Johnson",
      parentId: ROOT_DIRECTORY_ID,
      status: "Reviewed",
      updatedLabel: "Yesterday",
    },
  },
  {
    id: "financial-report",
    kind: "file",
    name: "Q4 Financial Report.pdf",
    mediaType: "application/pdf",
    size: 2_458_624,
    sizeLabel: "2.4 MB",
    description: "Sarah Chen",
    source: {
      owner: "Sarah Chen",
      parentId: ROOT_DIRECTORY_ID,
      status: "Reviewed",
      updatedLabel: "2 days ago",
    },
  },
];
apps/docs/src/components/examples/files-explorer/utils.ts
import type {
  FilesMoveEvent,
  FilesUploadEntry,
} from "@tilt-legal/cubitt-components/files";
import { formatBytes } from "@tilt-legal/cubitt-components/utilities/formatters";
import { ROOT_DIRECTORY_ID, rootDirectory } from "./data";
import type { ExplorerFolder, ExplorerItem, ExplorerSource } from "./types";

export function getChildren(
  items: ExplorerItem[],
  directoryId: string,
): ExplorerItem[] {
  return items
    .filter((item) => item.source?.parentId === directoryId)
    .sort((first, second) => {
      if (first.kind !== second.kind) {
        return first.kind === "folder" ? -1 : 1;
      }

      return first.name.localeCompare(second.name);
    });
}

export function getSelectedItems(
  items: ExplorerItem[],
  selectedIds: string[],
): ExplorerItem[] {
  const itemsById = new Map(items.map((item) => [item.id, item]));

  return selectedIds
    .map((id) => itemsById.get(id))
    .filter((item): item is ExplorerItem => Boolean(item));
}

export function getDirectoryPath(
  items: ExplorerItem[],
  directoryId: string,
): ExplorerFolder[] {
  const foldersById = new Map(
    items
      .filter((item): item is ExplorerFolder => item.kind === "folder")
      .map((folder) => [folder.id, folder]),
  );
  const path: ExplorerFolder[] = [];
  let currentDirectory =
    directoryId === ROOT_DIRECTORY_ID
      ? rootDirectory
      : (foldersById.get(directoryId) ?? rootDirectory);

  while (currentDirectory.id !== ROOT_DIRECTORY_ID) {
    path.unshift(currentDirectory);
    const parentId = currentDirectory.source?.parentId ?? ROOT_DIRECTORY_ID;
    currentDirectory =
      parentId === ROOT_DIRECTORY_ID
        ? rootDirectory
        : (foldersById.get(parentId) ?? rootDirectory);
  }

  return [rootDirectory, ...path];
}

export function createFolderItem(directoryId: string): ExplorerFolder {
  return {
    id: `folder-${globalThis.crypto.randomUUID()}`,
    kind: "folder",
    name: "Untitled folder",
    description: "0 items",
    source: {
      owner: "You",
      parentId: directoryId,
      status: "Draft",
      updatedLabel: "Just now",
    },
  };
}

export function createUploadedItems(
  entries: FilesUploadEntry[],
  directoryId: string,
): ExplorerItem[] {
  return entries.map((entry) => ({
    id: `file-${globalThis.crypto.randomUUID()}`,
    kind: "file",
    name: entry.name,
    mediaType: entry.mediaType,
    size: entry.size,
    sizeLabel: formatBytes(entry.size),
    description: "You",
    source: {
      owner: "You",
      parentId: directoryId,
      status: "Draft",
      updatedLabel: "Just now",
    },
  }));
}

export function removeItemsAndDescendants(
  items: ExplorerItem[],
  removedItems: ExplorerItem[],
): ExplorerItem[] {
  const removedIds = new Set(removedItems.map((item) => item.id));
  let changed = true;

  while (changed) {
    changed = false;

    for (const item of items) {
      if (
        !removedIds.has(item.id) &&
        item.source?.parentId &&
        removedIds.has(item.source.parentId)
      ) {
        removedIds.add(item.id);
        changed = true;
      }
    }
  }

  return items.filter((item) => !removedIds.has(item.id));
}

export function canMoveExplorerItems(
  items: ExplorerItem[],
  event: FilesMoveEvent<ExplorerSource>,
): boolean {
  const destinationId = getMoveDestinationDirectoryId(event);
  const movingIds = new Set(event.items.map((item) => item.id));

  if (event.destination.type === "folder" && movingIds.has(destinationId)) {
    return false;
  }

  if (event.items.every((item) => item.source?.parentId === destinationId)) {
    return false;
  }

  return event.items.every((item) => {
    if (item.kind !== "folder") {
      return true;
    }

    return !isDirectoryDescendant(items, item.id, destinationId);
  });
}

export function moveExplorerItems(
  items: ExplorerItem[],
  movedItems: ExplorerItem[],
  destinationId: string,
): ExplorerItem[] {
  const movedIds = new Set(movedItems.map((item) => item.id));

  return items.map((item) =>
    movedIds.has(item.id)
      ? {
          ...item,
          source: {
            owner: item.source?.owner ?? "You",
            parentId: destinationId,
            status: item.source?.status ?? "Draft",
            updatedLabel: "Just now",
          },
        }
      : item,
  );
}

export function getMoveDestinationDirectoryId(
  event: FilesMoveEvent<ExplorerSource>,
): string {
  return event.destination.type === "folder"
    ? event.destination.item.id
    : (event.destination.id ?? ROOT_DIRECTORY_ID);
}

function isDirectoryDescendant(
  items: ExplorerItem[],
  ancestorId: string,
  directoryId: string,
): boolean {
  const foldersById = new Map(
    items
      .filter((item): item is ExplorerFolder => item.kind === "folder")
      .map((folder) => [folder.id, folder]),
  );
  let currentDirectoryId: string | null = directoryId;

  while (currentDirectoryId && currentDirectoryId !== ROOT_DIRECTORY_ID) {
    if (currentDirectoryId === ancestorId) {
      return true;
    }

    currentDirectoryId =
      foldersById.get(currentDirectoryId)?.source?.parentId ??
      ROOT_DIRECTORY_ID;
  }

  return false;
}
apps/docs/src/components/examples/files-explorer/types.ts
import type { FilesItem } from "@tilt-legal/cubitt-components/files";

export type ExplorerSource = {
  owner: string;
  parentId: string | null;
  status: "Draft" | "Reviewed" | "Filed";
  updatedLabel: string;
};

export type ExplorerItem = FilesItem<ExplorerSource>;
export type ExplorerFolder = Extract<ExplorerItem, { kind: "folder" }>;
export type ExplorerActionMenuKind = "context" | "toolbar";

export type ExplorerActionHandlers = {
  onArchive: (items: ExplorerItem[]) => void;
  onCreateFolder: () => void;
  onDownload: (items: ExplorerItem[]) => void;
  onOpen: (item: ExplorerItem) => void;
  onRename: (item: ExplorerItem) => void;
  onViewDetails: () => void;
};