| import { createStore, produce, reconcile } from "solid-js/store" |
| import type { FileNode } from "@opencode-ai/sdk/v2" |
|
|
| type DirectoryState = { |
| expanded: boolean |
| loaded?: boolean |
| loading?: boolean |
| error?: string |
| children?: string[] |
| } |
|
|
| type TreeStoreOptions = { |
| scope: () => string |
| normalizeDir: (input: string) => string |
| list: (input: string) => Promise<FileNode[]> |
| onError: (message: string) => void |
| } |
|
|
| export function createFileTreeStore(options: TreeStoreOptions) { |
| const [tree, setTree] = createStore<{ |
| node: Record<string, FileNode> |
| dir: Record<string, DirectoryState> |
| }>({ |
| node: {}, |
| dir: { "": { expanded: true } }, |
| }) |
|
|
| const inflight = new Map<string, Promise<void>>() |
|
|
| const reset = () => { |
| inflight.clear() |
| setTree("node", reconcile({})) |
| setTree("dir", reconcile({})) |
| setTree("dir", "", { expanded: true }) |
| } |
|
|
| const ensureDir = (path: string) => { |
| if (tree.dir[path]) return |
| setTree("dir", path, { expanded: false }) |
| } |
|
|
| const listDir = (input: string, opts?: { force?: boolean }) => { |
| const dir = options.normalizeDir(input) |
| ensureDir(dir) |
|
|
| const current = tree.dir[dir] |
| if (!opts?.force && current?.loaded) return Promise.resolve() |
|
|
| const pending = inflight.get(dir) |
| if (pending) return pending |
|
|
| setTree( |
| "dir", |
| dir, |
| produce((draft) => { |
| draft.loading = true |
| draft.error = undefined |
| }), |
| ) |
|
|
| const directory = options.scope() |
|
|
| const promise = options |
| .list(dir) |
| .then((nodes) => { |
| if (options.scope() !== directory) return |
| const prevChildren = tree.dir[dir]?.children ?? [] |
| const nextChildren = nodes.map((node) => node.path) |
| const nextSet = new Set(nextChildren) |
|
|
| setTree( |
| "node", |
| produce((draft) => { |
| const removedDirs: string[] = [] |
|
|
| for (const child of prevChildren) { |
| if (nextSet.has(child)) continue |
| const existing = draft[child] |
| if (existing?.type === "directory") removedDirs.push(child) |
| delete draft[child] |
| } |
|
|
| if (removedDirs.length > 0) { |
| const keys = Object.keys(draft) |
| for (const key of keys) { |
| for (const removed of removedDirs) { |
| if (!key.startsWith(removed + "/")) continue |
| delete draft[key] |
| break |
| } |
| } |
| } |
|
|
| for (const node of nodes) { |
| draft[node.path] = node |
| } |
| }), |
| ) |
|
|
| setTree( |
| "dir", |
| dir, |
| produce((draft) => { |
| draft.loaded = true |
| draft.loading = false |
| draft.children = nextChildren |
| }), |
| ) |
| }) |
| .catch((e) => { |
| if (options.scope() !== directory) return |
| setTree( |
| "dir", |
| dir, |
| produce((draft) => { |
| draft.loading = false |
| draft.error = e.message |
| }), |
| ) |
| options.onError(e.message) |
| }) |
| .finally(() => { |
| inflight.delete(dir) |
| }) |
|
|
| inflight.set(dir, promise) |
| return promise |
| } |
|
|
| |
| |
| |
| const expandDir = (input: string, behavior?: { list?: boolean }) => { |
| const dir = options.normalizeDir(input) |
| ensureDir(dir) |
| setTree("dir", dir, "expanded", true) |
| if (behavior?.list === false) return |
| void listDir(dir) |
| } |
|
|
| const collapseDir = (input: string) => { |
| const dir = options.normalizeDir(input) |
| ensureDir(dir) |
| setTree("dir", dir, "expanded", false) |
| } |
|
|
| const dirState = (input: string) => { |
| const dir = options.normalizeDir(input) |
| return tree.dir[dir] |
| } |
|
|
| const children = (input: string) => { |
| const dir = options.normalizeDir(input) |
| const ids = tree.dir[dir]?.children |
| if (!ids) return [] |
| const out: FileNode[] = [] |
| for (const id of ids) { |
| const node = tree.node[id] |
| if (node) out.push(node) |
| } |
| return out |
| } |
|
|
| return { |
| listDir, |
| expandDir, |
| collapseDir, |
| dirState, |
| children, |
| node: (path: string) => tree.node[path], |
| isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), |
| reset, |
| } |
| } |
|
|