mono/packages/ui/src/apps/filebrowser/FileTree.tsx
2026-03-21 20:18:25 +01:00

620 lines
26 KiB
TypeScript

import React, { useMemo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronRight, Folder, FolderOpen, File, ArrowUp, Loader2 } from "lucide-react";
import { INode } from "@/modules/storage/types";
import { getMimeCategory } from "@/modules/storage/helpers";
import { cn } from "@/lib/utils";
export interface FileTreeProps {
data: INode[];
onSelect?: (node: INode) => void;
/** Fires whenever multi-select changes — passes all currently selected nodes */
onSelectionChange?: (nodes: INode[]) => void;
onActivate?: (node: INode) => void;
onGoUp?: () => void;
canGoUp?: boolean;
selectedId?: string;
className?: string;
/** Async loader for folder children. If provided, folders become expandable inline. */
fetchChildren?: (node: INode) => Promise<INode[]>;
fontSize?: number;
}
type TreeRow = {
id: string;
name: string;
node: INode;
isDir: boolean;
isNavUp: boolean;
depth: number;
parentId: string | null;
expanded: boolean;
loading: boolean;
};
interface ExpandState {
expanded: boolean;
children: INode[];
loading: boolean;
}
function sortDirsFirst(nodes: INode[]): INode[] {
return [...nodes].sort((a, b) => {
const aDir = getMimeCategory(a) === 'dir' ? 0 : 1;
const bDir = getMimeCategory(b) === 'dir' ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
}
function buildRows(
nodes: INode[],
canGoUp: boolean,
expandMap: Record<string, ExpandState>,
depth: number = 0,
parentId: string | null = null
): TreeRow[] {
const rows: TreeRow[] = [];
if (depth === 0 && canGoUp) {
rows.push({
id: '__nav_up__',
name: '..',
node: { name: '..', path: '..', type: 'dir', mime: 'inode/directory' } as INode,
isDir: true,
isNavUp: true,
depth: 0,
parentId: null,
expanded: false,
loading: false,
});
}
for (const n of nodes) {
const isDir = getMimeCategory(n) === 'dir';
const state = expandMap[n.path];
const expanded = state?.expanded ?? false;
const loading = state?.loading ?? false;
rows.push({
id: n.path,
name: n.name,
node: n,
isDir,
isNavUp: false,
depth,
parentId,
expanded,
loading,
});
if (isDir && expanded && state?.children?.length) {
const childRows = buildRows(
sortDirsFirst(state.children),
false,
expandMap,
depth + 1,
n.path
);
rows.push(...childRows);
}
}
return rows;
}
/** Search rows: try startsWith first, fall back to includes */
function findMatchIdx(rows: TreeRow[], str: string, startFrom: number, direction: 1 | -1 = 1): number {
const count = rows.length;
// Pass 1: startsWith
for (let i = 0; i < count; i++) {
const idx = ((startFrom + i * direction) % count + count) % count;
if (rows[idx].name.toLowerCase().startsWith(str)) return idx;
}
// Pass 2: includes
for (let i = 0; i < count; i++) {
const idx = ((startFrom + i * direction) % count + count) % count;
if (rows[idx].name.toLowerCase().includes(str)) return idx;
}
return -1;
}
export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>(
({ data, onSelect, onSelectionChange, onActivate, onGoUp, canGoUp = false, selectedId, className, fetchChildren, fontSize = 14 }, forwardedRef) => {
const [expandMap, setExpandMap] = useState<Record<string, ExpandState>>({});
const rows = useMemo(() => buildRows(data, canGoUp, expandMap), [data, canGoUp, expandMap]);
const [focusIdx, setFocusIdx] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const anchorIdx = useRef<number>(0); // anchor for shift-click range selection
const containerRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
// Type-ahead search
const searchBuf = useRef('');
const searchTimer = useRef<any>(null);
const [searchDisplay, setSearchDisplay] = useState(''); // mirrors searchBuf for rendering
// Merge forwarded ref
const setRef = useCallback((el: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (typeof forwardedRef === 'function') forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}, [forwardedRef]);
// Reset expand state when root data changes
useEffect(() => {
setExpandMap({});
setSelectedIds(new Set());
}, [data]);
// Sync selectedId prop from parent
useEffect(() => {
if (selectedId) {
// If it's already selected, do nothing
setSelectedIds(prev => {
if (prev.size === 1 && prev.has(selectedId)) return prev;
return new Set([selectedId]);
});
const idx = rows.findIndex(r => r.id === selectedId);
if (idx >= 0) {
setFocusIdx(idx);
}
} else if (selectedId === undefined && data.length === 0) {
setSelectedIds(new Set());
}
}, [selectedId, rows, data.length]);
// Auto-focus first real item when root data changes
const prevDataRef = useRef(data);
useLayoutEffect(() => {
if (data === prevDataRef.current && rows.length > 0) {
// If data hasn't changed but rows has, this is an expand/collapse operation. Don't reset.
return;
}
prevDataRef.current = data;
if (rows.length === 0) return;
// Give priority to selectedId provided by parent (e.g. return focus matching target)
if (selectedId) {
const existingIdx = rows.findIndex(r => r.id === selectedId);
if (existingIdx >= 0) {
containerRef.current?.focus({ preventScroll: true });
return;
}
}
const startIdx = canGoUp ? 1 : 0;
const idx = startIdx < rows.length ? startIdx : 0;
setFocusIdx(idx);
anchorIdx.current = idx;
containerRef.current?.focus({ preventScroll: true });
}, [data, canGoUp, rows, selectedId]);
// Scroll focused item into view
useEffect(() => {
rowRefs.current[focusIdx]?.scrollIntoView({ block: 'nearest' });
}, [focusIdx]);
// Stable ref for onSelectionChange to avoid infinite loop
// (inline arrow in parent JSX creates new ref every render)
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const rowsRef = useRef(rows);
rowsRef.current = rows;
// Notify parent when selection changes (only on selectedIds change, NOT rows)
useEffect(() => {
if (!onSelectionChangeRef.current) return;
const currentRows = rowsRef.current;
const selectedNodes = currentRows.filter(r => selectedIds.has(r.id) && !r.isNavUp).map(r => r.node);
onSelectionChangeRef.current(selectedNodes);
}, [selectedIds]);
// ── Expand / Collapse ────────────────────────────────────────
const toggleExpand = useCallback(async (row: TreeRow) => {
if (!row.isDir || row.isNavUp || !fetchChildren) return;
const path = row.node.path;
const current = expandMap[path];
if (current?.expanded) {
setExpandMap(prev => ({
...prev,
[path]: { ...prev[path], expanded: false },
}));
} else if (current?.children?.length) {
setExpandMap(prev => ({
...prev,
[path]: { ...prev[path], expanded: true },
}));
} else {
setExpandMap(prev => ({
...prev,
[path]: { expanded: true, children: [], loading: true },
}));
try {
const children = await fetchChildren(row.node);
setExpandMap(prev => ({
...prev,
[path]: { expanded: true, children, loading: false },
}));
} catch {
setExpandMap(prev => ({
...prev,
[path]: { expanded: false, children: [], loading: false },
}));
}
}
}, [expandMap, fetchChildren]);
/** Batch expand/collapse all selected folders */
const toggleExpandSelected = useCallback(async () => {
if (!fetchChildren || selectedIds.size === 0) return;
const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp);
if (selectedDirs.length === 0) return;
// If ANY are collapsed, expand all. If ALL expanded, collapse all.
const anyCollapsed = selectedDirs.some(r => !r.expanded);
if (!anyCollapsed) {
// Collapse all
setExpandMap(prev => {
const next = { ...prev };
for (const r of selectedDirs) {
next[r.node.path] = { ...next[r.node.path], expanded: false };
}
return next;
});
} else {
// Expand all (fetch as needed)
for (const r of selectedDirs) {
if (!r.expanded) {
await toggleExpand(r);
}
}
}
}, [fetchChildren, selectedIds, rows, toggleExpand]);
const collapseNode = useCallback((row: TreeRow) => {
if (!row.isDir || row.isNavUp) return;
setExpandMap(prev => ({
...prev,
[row.node.path]: { ...prev[row.node.path], expanded: false },
}));
}, []);
// Activate handler (enter / double-click)
const activate = useCallback((row: TreeRow) => {
if (row.isNavUp) {
onGoUp?.();
} else {
onActivate?.(row.node);
}
}, [onActivate, onGoUp]);
// ── Selection helpers ────────────────────────────────────────
/** Simple select — replaces selection with single item */
const selectRow = useCallback((idx: number) => {
setFocusIdx(idx);
anchorIdx.current = idx;
const row = rows[idx];
if (!row || row.isNavUp) {
setSelectedIds(new Set());
return;
}
setSelectedIds(new Set([row.id]));
onSelect?.(row.node);
}, [rows, onSelect]);
/** Ctrl+click — toggle item in/out of selection */
const toggleSelectRow = useCallback((idx: number) => {
setFocusIdx(idx);
anchorIdx.current = idx;
const row = rows[idx];
if (!row || row.isNavUp) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
}, [rows]);
/** Shift+click — range select from anchor to idx */
const rangeSelectTo = useCallback((idx: number) => {
setFocusIdx(idx);
const start = Math.min(anchorIdx.current, idx);
const end = Math.max(anchorIdx.current, idx);
const ids = new Set<string>();
for (let i = start; i <= end; i++) {
const r = rows[i];
if (r && !r.isNavUp) ids.add(r.id);
}
setSelectedIds(ids);
}, [rows]);
// Click handler
const handleClick = useCallback((idx: number, e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
toggleSelectRow(idx);
} else if (e.shiftKey) {
rangeSelectTo(idx);
} else {
selectRow(idx);
}
containerRef.current?.focus({ preventScroll: true });
}, [selectRow, toggleSelectRow, rangeSelectTo]);
// Keyboard handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.altKey) return;
const count = rows.length;
if (count === 0) return;
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
if (searchBuf.current) {
// Cycle to next match
const found = findMatchIdx(rows, searchBuf.current, focusIdx + 1, 1);
if (found >= 0) selectRow(found);
} else {
const next = focusIdx < count - 1 ? focusIdx + 1 : 0;
if (e.shiftKey) rangeSelectTo(next);
else selectRow(next);
}
break;
}
case 'ArrowUp': {
e.preventDefault();
if (searchBuf.current) {
// Cycle to previous match
const found = findMatchIdx(rows, searchBuf.current, focusIdx - 1 + count, -1);
if (found >= 0) selectRow(found);
} else {
const prev = focusIdx > 0 ? focusIdx - 1 : count - 1;
if (e.shiftKey) rangeSelectTo(prev);
else selectRow(prev);
}
break;
}
case 'ArrowRight': {
e.preventDefault();
// Multi-select: expand all selected collapsed dirs
if (fetchChildren && selectedIds.size > 1) {
const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp && !r.expanded);
if (selectedDirs.length > 0) {
for (const r of selectedDirs) toggleExpand(r);
break;
}
}
// Single-item behavior
const rowR = rows[focusIdx];
if (!rowR) break;
if (rowR.isDir && !rowR.isNavUp && fetchChildren) {
if (!rowR.expanded) {
toggleExpand(rowR);
} else {
if (focusIdx < count - 1) selectRow(focusIdx + 1);
}
} else {
if (focusIdx < count - 1) selectRow(focusIdx + 1);
}
break;
}
case 'ArrowLeft': {
e.preventDefault();
// Clean stale IDs (children of collapsed folders no longer in view)
const visibleIds = new Set(rows.map(r => r.id));
const cleanIds = new Set([...selectedIds].filter(id => visibleIds.has(id)));
if (cleanIds.size !== selectedIds.size) setSelectedIds(cleanIds);
// Multi-select: collapse all selected expanded dirs
if (fetchChildren && cleanIds.size > 1) {
const selectedDirs = rows.filter(r => cleanIds.has(r.id) && r.isDir && !r.isNavUp && r.expanded);
if (selectedDirs.length > 0) {
for (const r of selectedDirs) collapseNode(r);
break;
}
}
// Single-item behavior
const rowL = rows[focusIdx];
if (!rowL) break;
if (rowL.isDir && !rowL.isNavUp && rowL.expanded && fetchChildren) {
collapseNode(rowL);
} else if (rowL.parentId) {
const parentIdx = rows.findIndex(r => r.id === rowL.parentId);
if (parentIdx >= 0) {
const parentRow = rows[parentIdx];
if (parentRow) collapseNode(parentRow);
selectRow(parentIdx);
}
} else if (canGoUp) {
onGoUp?.();
}
break;
}
case 'Enter': {
e.preventDefault();
const row = rows[focusIdx];
if (row) activate(row);
break;
}
case 'Backspace': {
e.preventDefault();
if (searchBuf.current.length > 0) {
searchBuf.current = searchBuf.current.slice(0, -1);
if (searchBuf.current.length > 0) {
const found = findMatchIdx(rows, searchBuf.current, 0, 1);
if (found >= 0) selectRow(found);
}
} else if (canGoUp) {
onGoUp?.();
}
break;
}
case 'Home': {
e.preventDefault();
if (e.shiftKey) rangeSelectTo(0);
else selectRow(0);
break;
}
case 'End': {
e.preventDefault();
if (e.shiftKey) rangeSelectTo(count - 1);
else selectRow(count - 1);
break;
}
case ' ': {
e.preventDefault();
if (fetchChildren && selectedIds.size > 0) {
// Batch expand/collapse selected dirs
const hasSelectedDirs = rows.some(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp);
if (hasSelectedDirs) {
toggleExpandSelected();
break;
}
}
// Fallback: toggle single focused folder or activate file
const row = rows[focusIdx];
if (row?.isDir && !row.isNavUp && fetchChildren) {
toggleExpand(row);
} else if (row) {
activate(row);
}
break;
}
case 'a': {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
// Select all
const all = new Set<string>();
for (const r of rows) {
if (!r.isNavUp) all.add(r.id);
}
setSelectedIds(all);
}
break;
}
case 'Escape': {
e.preventDefault();
if (searchBuf.current) {
searchBuf.current = '';
setSearchDisplay('');
} else {
setSelectedIds(new Set());
}
break;
}
default: {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key.toLowerCase();
searchBuf.current += char;
setSearchDisplay(searchBuf.current);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => { searchBuf.current = ''; setSearchDisplay(''); }, 10000);
const str = searchBuf.current;
const found = findMatchIdx(rows, str, focusIdx, 1);
if (found >= 0) selectRow(found);
}
break;
}
}
}, [focusIdx, rows, canGoUp, onGoUp, activate, selectRow, toggleExpand, collapseNode, fetchChildren, rangeSelectTo, selectedIds, toggleExpandSelected]);
return (
<div
ref={setRef}
data-testid="file-tree"
className={cn("w-full h-full min-h-0 overflow-y-auto outline-none fb-tree-container p-1", className)}
tabIndex={0}
onKeyDown={handleKeyDown}
>
{rows.map((row, idx) => {
const isSelected = selectedIds.has(row.id);
const isFocused = focusIdx === idx;
return (
<div
key={row.id}
data-testid="file-tree-node"
data-node-id={row.id}
ref={(el) => { rowRefs.current[idx] = el; }}
className={cn(
"flex items-center gap-1 py-0.5 cursor-pointer select-none rounded group",
isSelected && !isFocused && "bg-blue-100 dark:bg-accent/60 text-blue-900 dark:text-accent-foreground",
isFocused && "bg-blue-50 dark:bg-accent/80 text-foreground ring-1 ring-blue-400 dark:ring-ring",
!isSelected && !isFocused && "hover:bg-accent/40",
)}
style={{ fontSize: fontSize, paddingLeft: `${row.depth * 16 + 4}px`, paddingRight: 8 }}
onClick={(e) => handleClick(idx, e)}
onDoubleClick={() => activate(row)}
>
{/* Expand/collapse chevron */}
{row.isDir && !row.isNavUp && fetchChildren ? (
<button
type="button"
className="flex items-center justify-center w-4 h-4 shrink-0 text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpand(row);
}}
tabIndex={-1}
>
{row.loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<ChevronRight
size={14}
className={cn(
"transition-transform duration-150",
row.expanded && "rotate-90"
)}
/>
)}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{/* Icon */}
<div className="flex items-center justify-center text-muted-foreground shrink-0">
{row.isNavUp ? (
<ArrowUp size={16} />
) : row.isDir ? (
row.expanded ? (
<FolderOpen size={16} className="text-blue-400 fill-blue-400/20" />
) : (
<Folder size={16} className="text-blue-400 fill-blue-400/20" />
)
) : (
<File size={16} />
)}
</div>
{/* Name — highlight type-ahead match on focused row */}
<span className="truncate flex-1">
{(() => {
if (!isFocused || !searchDisplay) return row.name;
const lower = row.name.toLowerCase();
const pos = lower.startsWith(searchDisplay) ? 0 : lower.indexOf(searchDisplay);
if (pos < 0) return row.name;
return (
<>
{pos > 0 && row.name.slice(0, pos)}
<span className="bg-amber-200/80 text-amber-900 border-b border-amber-500 dark:bg-sky-500/30 dark:text-sky-200 dark:border-sky-400">
{row.name.slice(pos, pos + searchDisplay.length)}
</span>
{row.name.slice(pos + searchDisplay.length)}
</>
);
})()}
</span>
</div>
);
})}
</div>
);
}
);
FileTree.displayName = 'FileTree';