import { useEffect, useMemo, useRef, useState } from "react";
import TreeItem from "./src/components/TreeItem";
import ContextMenu, { placeMenu } from "./src/components/ContextMenu";
import {
initialTree,
makeId,
sortNodes,
indexTree,
pathTo,
filterTree,
countMatches,
updateNode,
removeNode,
insertNode,
uniqueName,
} from "./src/utils/tree";
export default function App() {
const [tree, setTree] = useState(initialTree);
const [expanded, setExpanded] = useState(() => new Set(["src"]));
const [selectedId, setSelectedId] = useState(null);
const [query, setQuery] = useState("");
const [menu, setMenu] = useState(null);
const [renamingId, setRenamingId] = useState(null);
const [renameError, setRenameError] = useState("");
const [status, setStatus] = useState("");
const treeRef = useRef(null);
const focusPending = useRef(null);
const { byId, parentOf } = useMemo(() => indexTree(tree), [tree]);
const q = query.trim().toLowerCase();
const view = useMemo(() => {
if (!q) return { nodes: tree, open: expanded, matches: null };
const open = new Set();
return {
nodes: filterTree(tree, q, open),
open,
matches: countMatches(tree, q),
};
}, [tree, expanded, q]);
const selected = byId.get(selectedId) ?? null;
const folderId = !selected
? null
: selected.type === "folder"
? selected.id
: parentOf.get(selected.id);
const crumbs = folderId ? pathTo(folderId, byId, parentOf) : [];
const visible = sortNodes(view.nodes);
const visibleIds = useMemo(() => {
const ids = new Set();
const walk = (ns) =>
ns.forEach((n) => {
ids.add(n.id);
if (n.children && view.open.has(n.id)) walk(n.children);
});
walk(view.nodes);
return ids;
}, [view]);
const tabStopId = visibleIds.has(selectedId) ? selectedId : visible[0]?.id;
useEffect(() => {
if (!focusPending.current) return;
treeRef.current
.querySelector(`[data-id="${focusPending.current}"]`)
?.focus();
focusPending.current = null;
});
function reveal(id, includeSelf) {
setExpanded((prev) => {
const next = new Set(prev);
for (
let cur = includeSelf ? id : parentOf.get(id);
cur != null;
cur = parentOf.get(cur)
)
next.add(cur);
return next;
});
}
function navigate(id) {
setSelectedId(id);
if (id) reveal(id, true);
setQuery("");
}
function onRowClick(node) {
setSelectedId(node.id);
reveal(node.id, false);
if (node.type === "folder" && !q) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
return next;
});
}
}
const MENU_ITEMS = 4;
function openMenu(e, node) {
e.preventDefault();
setSelectedId(node.id);
setMenu({
...placeMenu(e.clientX, e.clientY, MENU_ITEMS),
nodeId: node.id,
});
}
function onRowKey(e, node) {
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setSelectedId(node.id);
setMenu({
...placeMenu(r.left + 24, r.bottom, MENU_ITEMS),
nodeId: node.id,
});
}
}
function closeMenu(returnFocus) {
if (returnFocus && menu) focusPending.current = menu.nodeId;
setMenu(null);
}
function create(node, type) {
const parentId = node.type === "folder" ? node.id : parentOf.get(node.id);
const siblings = parentId === null ? tree : byId.get(parentId).children;
const item = { id: makeId(), name: uniqueName(siblings, type), type };
if (type === "folder") item.children = [];
setTree((t) => insertNode(t, parentId, item));
if (parentId) reveal(parentId, true);
setSelectedId(item.id);
focusPending.current = item.id;
setStatus(
"Created " +
item.name +
" in " +
(parentId ? byId.get(parentId).name : "Project"),
);
}
function remove(node) {
const parentId = parentOf.get(node.id);
setTree((t) => removeNode(t, node.id));
setSelectedId(parentId);
focusPending.current = parentId;
setStatus("Deleted " + node.name);
}
function commitRename(raw) {
const name = raw.trim();
const node = byId.get(renamingId);
if (!name || name === node.name) {
cancelRename();
return;
}
const parentId = parentOf.get(node.id);
const siblings = parentId === null ? tree : byId.get(parentId).children;
if (
siblings.some(
(s) => s.id !== node.id && s.name.toLowerCase() === name.toLowerCase(),
)
) {
setRenameError('"' + name + '" already exists here');
return;
}
setTree((t) => updateNode(t, node.id, (n) => ({ ...n, name })));
setStatus("Renamed " + node.name + " to " + name);
cancelRename();
}
function cancelRename() {
focusPending.current = renamingId;
setRenamingId(null);
setRenameError("");
}
const target = menu ? byId.get(menu.nodeId) : null;
const menuItems = target
? [
{ label: "New file", run: () => create(target, "file") },
{ label: "New folder", run: () => create(target, "folder") },
{
label: "Rename",
run: () => {
setRenamingId(target.id);
setRenameError("");
},
},
{ label: "Delete", danger: true, run: () => remove(target) },
]
: [];
const api = {
query: q,
selectedId,
tabStopId,
renamingId,
renameError,
isOpen: (id) => view.open.has(id),
onRowClick,
onRowKey,
openMenu,
commitRename,
cancelRename,
};
return (
<div className="app">
<nav aria-label="Breadcrumb" className="crumbs" data-testid="breadcrumbs">
<ol>
<li>
{crumbs.length ? (
<button onClick={() => navigate(null)}>Project</button>
) : (
<span aria-current="page">Project</span>
)}
</li>
{crumbs.map((c, i) => (
<li key={c.id}>
<span className="sep" aria-hidden="true">
/
</span>
{i === crumbs.length - 1 ? (
<span aria-current="page">{c.name}</span>
) : (
<button onClick={() => navigate(c.id)}>{c.name}</button>
)}
</li>
))}
</ol>
</nav>
<input
type="search"
className="search"
data-testid="search"
placeholder="Filter files..."
aria-label="Filter files"
aria-controls="file-tree"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<p className="summary" aria-live="polite" data-testid="summary">
{view.matches === null
? ""
: view.matches
? view.matches + " match" + (view.matches === 1 ? "" : "es")
: 'No files match "' + query.trim() + '"'}
</p>
<ul
id="file-tree"
role="tree"
aria-label="Project files"
className="tree"
ref={treeRef}
>
{visible.map((n) => (
<TreeItem key={n.id} node={n} depth={1} api={api} />
))}
</ul>
<p className="status" role="status" data-testid="status">
{status}
</p>
{menu && target && (
<ContextMenu
left={menu.left}
top={menu.top}
label={"Actions for " + target.name}
items={menuItems}
onClose={closeMenu}
/>
)}
</div>
);
}