import { useStore } from '@nanostores/react' import { atom } from 'nanostores' import { useCallback, useEffect, useMemo } from 'react' import { clearProjectDirCache, readProjectDir } from './ipc' export interface TreeNode { /** Absolute filesystem path. Doubles as react-arborist node id. */ id: string name: string /** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */ isDirectory: boolean /** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */ children?: TreeNode[] /** True while a readDir for this folder is in flight. */ loading?: boolean /** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */ error?: string } const PLACEHOLDER_ID = '__loading__' function makeNode(path: string, name: string, isDirectory: boolean): TreeNode { return { id: path, isDirectory, name } } function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] { if (!nodes) { return [] } return nodes.map(n => { if (n.id === id) { return patch(n) } if (n.children && n.children.length > 0) { return { ...n, children: patchNode(n.children, id, patch) } } return n }) } function placeholderChild(parentId: string): TreeNode { return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' } } export interface UseProjectTreeResult { data: TreeNode[] openState: Record rootError: string | null rootLoading: boolean loadChildren: (id: string) => Promise refreshRoot: () => Promise setNodeOpen: (id: string, open: boolean) => void } interface ProjectTreeState { cwd: string data: TreeNode[] loaded: boolean openState: Record requestId: number rootError: string | null rootLoading: boolean } const initialState: ProjectTreeState = { cwd: '', data: [], loaded: false, openState: {}, requestId: 0, rootError: null, rootLoading: false } const inflight = new Set() const $projectTree = atom(initialState) let nextRootRequestId = 0 function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) { $projectTree.set(updater($projectTree.get())) } function clearProjectTree() { nextRootRequestId += 1 inflight.clear() $projectTree.set({ ...initialState, requestId: nextRootRequestId }) } async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) { if (!cwd) { clearProjectTree() return } const current = $projectTree.get() if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) { return } const requestId = nextRootRequestId + 1 nextRootRequestId = requestId inflight.clear() if (force || current.cwd !== cwd) { clearProjectDirCache(cwd) } $projectTree.set({ cwd, data: [], loaded: false, openState: current.cwd === cwd ? current.openState : {}, requestId, rootError: null, rootLoading: true }) const { entries, error } = await readProjectDir(cwd, cwd) setProjectTree(latest => { if (latest.cwd !== cwd || latest.requestId !== requestId) { return latest } return { ...latest, data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)), loaded: true, rootError: error || null, rootLoading: false } }) } export function resetProjectTreeState() { clearProjectTree() clearProjectDirCache() } /** * Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first * expand and cached in this feature-owned atom so unrelated chat rerenders or * remounts cannot reset the browser. A placeholder leaf renders so the * disclosure caret shows for unloaded folders. `refreshRoot` invalidates the * whole tree (used after cwd change or manual refresh). */ export function useProjectTree(cwd: string): UseProjectTreeResult { const state = useStore($projectTree) const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd]) const setNodeOpen = useCallback( (id: string, open: boolean) => { setProjectTree(current => { if (current.cwd !== cwd || current.openState[id] === open) { return current } return { ...current, openState: { ...current.openState, [id]: open } } }) }, [cwd] ) const loadChildren = useCallback( async (id: string) => { if (!cwd || inflight.has(id)) { return } inflight.add(id) setProjectTree(current => { if (current.cwd !== cwd) { return current } return { ...current, data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] })) } }) const { entries, error } = await readProjectDir(id, cwd) inflight.delete(id) setProjectTree(current => { if (current.cwd !== cwd) { return current } return { ...current, data: patchNode(current.data, id, n => ({ ...n, loading: false, error: error || undefined, children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)) })) } }) }, [cwd] ) useEffect(() => { void loadRoot(cwd) }, [cwd]) return useMemo( () => ({ data: state.cwd === cwd ? state.data : [], loadChildren, openState: state.cwd === cwd ? state.openState : {}, refreshRoot, rootError: state.cwd === cwd ? state.rootError : null, rootLoading: state.cwd === cwd ? state.rootLoading : false, setNodeOpen }), [ cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading ] ) }