620 lines
26 KiB
TypeScript
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';
|