import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePreferencesStore } from "@/modules/settings/preferences"; export type DirEntry = { name: string; kind: "file" | "dir" | "symlink"; size: number; mtime: number; }; type ChildrenState = | { status: "idle" } | { status: "loading" } | { status: "loaded"; entries: DirEntry[] } | { status: "error"; message: string }; type TreeState = Record; export type PendingCreate = { parentPath: string; kind: "file" | "dir"; }; export function joinPath(parent: string, name: string): string { if (parent.endsWith("/")) return `${parent}${name}`; return `${parent}/${name}`; } export function dirname(path: string): string { const i = path.lastIndexOf("/"); if (i <= 0) return "/"; return path.slice(0, i); } type Options = { onPathRenamed?: (from: string, to: string) => void; onPathDeleted?: (path: string) => void; }; export function useFileTree(rootPath: string | null, options?: Options) { const showHidden = usePreferencesStore((s) => s.showHidden); const showHiddenRef = useRef(showHidden); const [nodes, setNodes] = useState({}); const [expanded, setExpanded] = useState>(new Set()); const [pendingCreate, setPendingCreate] = useState( null, ); const [renaming, setRenaming] = useState(null); useEffect(() => { showHiddenRef.current = showHidden; }, [showHidden]); const fetchChildren = useCallback(async (path: string) => { setNodes((s) => ({ ...s, [path]: { status: "loading" } })); try { const entries = await invoke("fs_read_dir", { path, showHidden: showHiddenRef.current, }); setNodes((s) => ({ ...s, [path]: { status: "loaded", entries } })); } catch (e) { setNodes((s) => ({ ...s, [path]: { status: "error", message: String(e) }, })); } }, []); // Root change → reset state. useEffect(() => { if (!rootPath) { setNodes({}); setExpanded(new Set()); setPendingCreate(null); setRenaming(null); return; } setPendingCreate(null); setRenaming(null); setExpanded(new Set()); setNodes({}); void fetchChildren(rootPath); }, [rootPath, fetchChildren]); useEffect(() => { if (!rootPath) return; const loadedPaths = Object.entries(nodes) .filter(([, state]) => state.status === "loaded") .map(([path]) => path); for (const path of loadedPaths) void fetchChildren(path); // Re-list loaded directories when the visibility preference changes. // `nodes` is intentionally omitted so ordinary tree edits don't refetch // every expanded directory. // eslint-disable-next-line react-hooks/exhaustive-deps }, [showHidden, rootPath, fetchChildren]); const toggle = useCallback( (path: string) => { setExpanded((curr) => { const next = new Set(curr); if (next.has(path)) next.delete(path); else next.add(path); return next; }); setNodes((curr) => { if (!curr[path] || curr[path].status === "error") { void fetchChildren(path); } return curr; }); }, [fetchChildren], ); const expand = useCallback( (path: string) => { setExpanded((curr) => { if (curr.has(path)) return curr; const next = new Set(curr); next.add(path); return next; }); setNodes((curr) => { if (!curr[path]) void fetchChildren(path); return curr; }); }, [fetchChildren], ); const refresh = useCallback( (path: string) => { void fetchChildren(path); }, [fetchChildren], ); // --- mutations --- const beginCreate = useCallback( (parentPath: string, kind: "file" | "dir") => { setRenaming(null); setPendingCreate({ parentPath, kind }); // Ensure the parent is expanded so the input row is visible. if (rootPath && parentPath !== rootPath) { setExpanded((curr) => { if (curr.has(parentPath)) return curr; const next = new Set(curr); next.add(parentPath); return next; }); } setNodes((curr) => { if (!curr[parentPath]) void fetchChildren(parentPath); return curr; }); }, [rootPath, fetchChildren], ); const cancelCreate = useCallback(() => setPendingCreate(null), []); const commitCreate = useCallback( async (name: string) => { if (!pendingCreate) return; const trimmed = name.trim(); if (!trimmed) { setPendingCreate(null); return; } const path = joinPath(pendingCreate.parentPath, trimmed); const cmd = pendingCreate.kind === "dir" ? "fs_create_dir" : "fs_create_file"; try { await invoke(cmd, { path }); await fetchChildren(pendingCreate.parentPath); } catch (e) { console.error(`${cmd} failed:`, e); } finally { setPendingCreate(null); } }, [pendingCreate, fetchChildren], ); const beginRename = useCallback((path: string) => { setPendingCreate(null); setRenaming(path); }, []); const cancelRename = useCallback(() => setRenaming(null), []); const commitRename = useCallback( async (newName: string) => { if (!renaming) return; const trimmed = newName.trim(); const parent = dirname(renaming); const oldName = renaming.slice(parent === "/" ? 1 : parent.length + 1); if (!trimmed || trimmed === oldName) { setRenaming(null); return; } const to = joinPath(parent, trimmed); try { await invoke("fs_rename", { from: renaming, to }); options?.onPathRenamed?.(renaming, to); await fetchChildren(parent); } catch (e) { console.error("fs_rename failed:", e); } finally { setRenaming(null); } }, [renaming, fetchChildren, options], ); const deletePath = useCallback( async (path: string) => { try { await invoke("fs_delete", { path }); options?.onPathDeleted?.(path); await fetchChildren(dirname(path)); } catch (e) { console.error("fs_delete failed:", e); } }, [fetchChildren, options], ); return { nodes, expanded, pendingCreate, renaming, toggle, expand, refresh, beginCreate, cancelCreate, commitCreate, beginRename, cancelRename, commitRename, deletePath, joinPath, }; }