246 lines
5.9 KiB
TypeScript
246 lines
5.9 KiB
TypeScript
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<string, boolean>
|
|
rootError: string | null
|
|
rootLoading: boolean
|
|
loadChildren: (id: string) => Promise<void>
|
|
refreshRoot: () => Promise<void>
|
|
setNodeOpen: (id: string, open: boolean) => void
|
|
}
|
|
|
|
interface ProjectTreeState {
|
|
cwd: string
|
|
data: TreeNode[]
|
|
loaded: boolean
|
|
openState: Record<string, boolean>
|
|
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<string>()
|
|
const $projectTree = atom<ProjectTreeState>(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
|
|
]
|
|
)
|
|
}
|