import {
ChevronDown,
ChevronRight,
File,
Folder,
type LucideIcon,
} from 'lucide-react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useMediaQuery } from '@/hooks';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../ui/command';
import { Modal } from '../ui/modal';
import {
ancestorIds,
filterNavigatorLeaves,
findNode,
type NavigatorNode,
} from './navigatorUtils';
const NO_HIGHLIGHT = '__none__';
/**
* Tree indent, before the row's icons: a 24px step per level through depth 4
* (96px), then 12px per further level, so deep rows still step in but keep
* room for a label in the w-64 column.
*/
function Indent({ depth }: { depth: number }) {
if (depth <= 0) return null;
const width = Math.min(depth, 4) * 24 + Math.max(depth - 4, 0) * 12;
return (
);
}
/** The folders to expand to show a node: its ancestors, and itself if a folder. */
function revealIds(nodes: NavigatorNode[], id: string): string[] {
const trail = ancestorIds(nodes, id);
return findNode(nodes, id)?.kind === 'folder' ? [...trail, id] : trail;
}
export interface SourceNavigatorProps {
nodes: NavigatorNode[];
/** The open leaf or folder. */
selectedId: string | null;
onSelect: (node: NavigatorNode) => void;
/** Placeholder and accessible name of the filter ("Filter files"). */
filterLabel: string;
/** Shown when the filter matches nothing. */
emptyLabel: string;
/** Names the phone picker button and its sheet ("Files", "Pages"). */
title: string;
/**
* `tree`: folders expand and collapse, and a folder can be opened (the file
* tree). `groups`: each folder is a heading over its pages (the wiki).
*/
folderMode?: 'tree' | 'groups';
leafIcon?: LucideIcon;
/** Layout only. */
className?: string;
}
/**
* The navigator column of a source view: a filter field over the source's
* files or pages, both in one Command, so the arrow keys walk from the field
* into the list. The open item is `checked`. Below `lg` it collapses into a
* combobox that opens the same list in a bottom sheet.
*/
export default function SourceNavigator({
nodes,
selectedId,
onSelect,
filterLabel,
emptyLabel,
title,
folderMode = 'tree',
leafIcon: LeafIcon = File,
className,
}: SourceNavigatorProps) {
const { isDesktop } = useMediaQuery();
const [query, setQuery] = useState('');
const [sheetOpen, setSheetOpen] = useState(false);
// cmdk highlights its first row by default, which reads as hover; start the
// highlight on the open item instead and let the keys and pointer move it.
// With nothing open, a value that matches no row: an empty one lets cmdk
// pick the first row again.
const [highlight, setHighlight] = useState(selectedId ?? NO_HIGHLIGHT);
useEffect(() => setHighlight(selectedId ?? NO_HIGHLIGHT), [selectedId]);
const [expanded, setExpanded] = useState>(
() => new Set(selectedId ? revealIds(nodes, selectedId) : []),
);
// Opening an item elsewhere (a crumb, a table row, a search) reveals it in
// the tree; an opened folder expands too. Other open folders stay open.
useEffect(() => {
if (!selectedId) return;
const trail = revealIds(nodes, selectedId);
if (trail.length === 0) return;
setExpanded((prev) =>
trail.every((id) => prev.has(id)) ? prev : new Set([...prev, ...trail]),
);
}, [nodes, selectedId]);
const matches = useMemo(
() => filterNavigatorLeaves(nodes, query),
[nodes, query],
);
const choose = (node: NavigatorNode) => {
if (node.kind === 'folder' && folderMode === 'tree') {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(node.id) && node.id === selectedId) next.delete(node.id);
else next.add(node.id);
return next;
});
}
onSelect(node);
if (node.kind === 'leaf') {
setQuery('');
setSheetOpen(false);
}
};
const leafItem = (node: NavigatorNode, depth: number, meta?: string) => (
choose(node)}
title={node.path}
>
{node.label}
{meta ? (
{meta}
) : null}
);
const treeRows = (list: NavigatorNode[], depth: number): React.ReactNode =>
list.map((node) => {
if (node.kind === 'leaf') return leafItem(node, depth);
const open = expanded.has(node.id);
const Chevron = open ? ChevronDown : ChevronRight;
return (
choose(node)}
aria-expanded={open}
title={node.path}
>
{node.label}
{node.count !== undefined ? (
{node.count}
) : null}
{open ? treeRows(node.children ?? [], depth + 1) : null}
);
});
const list = (
{/* The column caps its own list; in the phone sheet the Modal body
scrolls, so the list grows with it (no second scroller). */}
{query.trim() ? (
matches.length === 0 ? (
{emptyLabel}
) : (
{matches.map(({ node, parentPath }) =>
leafItem(node, 0, parentPath || undefined),
)}
)
) : folderMode === 'groups' ? (
<>
{nodes
.filter((node) => node.kind === 'leaf')
.map((node) => leafItem(node, 0))}
{nodes
.filter((node) => node.kind === 'folder')
.map((folder) => (
{(folder.children ?? []).map((node) => leafItem(node, 0))}
))}
>
) : (
{treeRows(nodes, 0)}
)}
);
if (isDesktop) {
return (
);
}
const current = selectedId ? findNode(nodes, selectedId) : undefined;
return (
<>