import { useEffect, useMemo, useRef, useState } from "react";
import TreeNode, { NewItemRow } from "./src/components/TreeNode";
import {
initialTree,
makeId,
sortNodes,
findNode,
findParentId,
updateNode,
removeNode,
insertNode,
countDescendants,
visibleRows,
validateName,
} from "./src/utils/treeUtils";
export default function App() {
const [tree, setTree] = useState(initialTree);
const [expanded, setExpanded] = useState(() => new Set());
const [selectedId, setSelectedId] = useState(null);
const [editing, setEditing] = useState(null);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const treeRef = useRef(null);
const focusPending = useRef(false);
const rows = useMemo(() => visibleRows(tree, expanded), [tree, expanded]);
const selected = selectedId ? findNode(tree, selectedId) : null;
const tabStopId = rows.some((r) => r.id === selectedId)
? selectedId
: rows[0]?.id;
useEffect(() => {
if (!focusPending.current || !selectedId) return;
focusPending.current = false;
treeRef.current.querySelector(`[data-id="${selectedId}"]`)?.focus();
});
function setOpen(id, open) {
setExpanded((prev) => {
const next = new Set(prev);
if (open) next.add(id);
else next.delete(id);
return next;
});
}
function select(id) {
setSelectedId(id);
focusPending.current = true;
}
function startNew(type) {
let parentId = null;
if (selected)
parentId =
selected.type === "folder"
? selected.id
: findParentId(tree, selected.id);
if (parentId) setOpen(parentId, true);
setEditing({ kind: "new", parentId, type });
setError("");
}
function startRename(node) {
if (!node) return;
setSelectedId(node.id);
setEditing({ kind: "rename", id: node.id });
setError("");
}
function cancel() {
setEditing(null);
setError("");
focusPending.current = true;
}
function commit(raw) {
const name = raw.trim();
if (!name) {
cancel();
return;
}
const parentId =
editing.kind === "new"
? editing.parentId
: findParentId(tree, editing.id);
const problem = validateName(
tree,
parentId,
name,
editing.kind === "rename" ? editing.id : null,
);
if (problem) {
setError(problem);
return;
}
if (editing.kind === "new") {
const node = { id: makeId(), name, type: editing.type };
if (editing.type === "folder") node.children = [];
setTree((t) => insertNode(t, parentId, node));
setSelectedId(node.id);
setStatus("Created " + name);
} else {
setTree((t) => updateNode(t, editing.id, (n) => ({ ...n, name })));
setStatus("Renamed to " + name);
}
setEditing(null);
setError("");
focusPending.current = true;
}
function remove(node) {
if (!node) return;
const inside = countDescendants(node);
const parentId = findParentId(tree, node.id);
setTree((t) => removeNode(t, node.id));
setSelectedId(parentId);
focusPending.current = true;
setStatus(
"Deleted " +
node.name +
(inside
? " and " + inside + " item" + (inside === 1 ? "" : "s") + " inside"
: ""),
);
}
function onRowClick(node) {
setSelectedId(node.id);
if (node.type === "folder") setOpen(node.id, !expanded.has(node.id));
}
function onKeyDown(e, id) {
const i = rows.findIndex((r) => r.id === id);
const { node, parentId } = rows[i];
const isFolder = node.type === "folder";
const open = expanded.has(id);
if (e.key === "ArrowDown") {
if (rows[i + 1]) select(rows[i + 1].id);
} else if (e.key === "ArrowUp") {
if (rows[i - 1]) select(rows[i - 1].id);
} else if (e.key === "ArrowRight") {
if (isFolder && !open) setOpen(id, true);
else if (isFolder && node.children.length) select(rows[i + 1].id);
} else if (e.key === "ArrowLeft") {
if (isFolder && open) setOpen(id, false);
else if (parentId) select(parentId);
} else if (e.key === "Enter") {
setSelectedId(id);
if (isFolder) setOpen(id, !open);
} else if (e.key === "F2") startRename(node);
else if (e.key === "Delete") remove(node);
else return;
e.preventDefault();
}
const api = {
expanded,
selectedId,
tabStopId,
editing,
error,
onRowClick,
onKeyDown,
commit,
cancel,
};
return (
<div className="app">
<h1>Files</h1>
<div className="toolbar" role="toolbar" aria-label="File actions">
<button data-testid="new-file" onClick={() => startNew("file")}>
+ File
</button>
<button data-testid="new-folder" onClick={() => startNew("folder")}>
+ Folder
</button>
<button
data-testid="rename"
disabled={!selected}
onClick={() => startRename(selected)}
>
Rename
</button>
<button
data-testid="delete"
disabled={!selected}
onClick={() => remove(selected)}
>
Delete
</button>
</div>
<ul role="tree" aria-label="Project files" className="tree" ref={treeRef}>
{sortNodes(tree).map((node) => (
<TreeNode key={node.id} node={node} depth={1} api={api} />
))}
{editing?.kind === "new" && editing.parentId === null && (
<NewItemRow depth={1} type={editing.type} api={api} />
)}
</ul>
<p className="status" role="status" data-testid="status">
{status}
</p>
</div>
);
}