import { createSignal, createResource, createMemo, onCleanup, type JSX, For, Show } from "solid-js" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { FONT_MONO, FONT_SANS } from "@/styles/tokens" import { IconFolder, IconFile, IconChevronRight, IconChevronDown, IconRefresh, IconSearch } from "@/thesis/shared/Icon" interface FileNode { name: string path: string absolute: string type: "file" | "directory" ignored: boolean } const EXT_COLOR: Record = { py: "#3776AB", ts: "#3178C6", tsx: "#3178C6", js: "#F7DF1E", jsx: "#F7DF1E", json: "#888892", yaml: "#CB171E", yml: "#CB171E", toml: "#9C4221", md: "var(--color-text-faint)", ipynb: "#F37626", parquet: "#50C878", jsonl: "#888892", rs: "#CE412B", go: "#00ADD8", swift: "#F05138", java: "#B07219", rb: "#CC342D", sh: "#89E051", css: "#563D7C", html: "#E34F26", c: "#555555", cpp: "#F34B7D", h: "#555555", } const ext = (name: string): string => { const i = name.lastIndexOf(".") return i > 0 ? name.slice(i + 1).toLowerCase() : "" } /** Show every entry — including gitignored / secret-flagged files like * `.pem` — but sink ignored entries to the bottom so the working set * stays at the top. Directories before files within each tier. */ function sortNodes(nodes: FileNode[]): FileNode[] { return [...nodes].sort((a, b) => { if (a.ignored !== b.ignored) return a.ignored ? 1 : -1 if (a.type !== b.type) return a.type === "directory" ? -1 : 1 return a.name.localeCompare(b.name) }) } /** * Live file tree backed by sdk.client.file.list (the same endpoint the CLI * uses). Folders fetch lazily on expand. Filter shrinks to matches as the * user types. */ export function OpenScienceFileTree(props: { onOpen?: (path: string) => void }): JSX.Element { const sdk = useSDK() const sync = useSync() const directory = () => sync.project?.worktree || sync.data.path.directory || sdk.directory const [filter, setFilter] = createSignal("") // Debounced query drives match filtering (each visible Node re-evaluates // `matches()` on change) so typing doesn't thrash the whole tree per keystroke. const [query, setQuery] = createSignal("") let filterTimer: ReturnType | undefined const onFilter = (v: string) => { setFilter(v) clearTimeout(filterTimer) filterTimer = setTimeout(() => setQuery(v), 110) } onCleanup(() => clearTimeout(filterTimer)) const [expanded, setExpanded] = createSignal>(new Set()) const [refreshKey, setRefreshKey] = createSignal(0) const [permissionError, setPermissionError] = createSignal(null) const [root] = createResource( () => [directory(), refreshKey()] as const, async ([dir]) => { setPermissionError(null) if (!dir) return [] as FileNode[] try { const res: any = await sdk.client.file.list({ directory: dir, path: "." } as any) const data = res?.data ?? res if (Array.isArray(data)) return data as FileNode[] return [] } catch (err: any) { const status = err?.response?.status ?? err?.status ?? err?.statusCode // The backend now returns 403 with a TCC-aware message when readdir // hits EACCES/EPERM — surface that to the UI so we can prompt for // Full Disk Access on macOS instead of showing "0 entries". if (status === 403) { const message = err?.body?.message ?? err?.message ?? "OpenScience cannot read this directory" setPermissionError(String(message)) } return [] as FileNode[] } }, ) // Stale-while-revalidate: `root.latest` keeps the last tree on screen while a // refresh/directory change refetches, so the tree never blanks to "loading…". const rootRows = createMemo(() => sortNodes(root.latest ?? [])) const totalCount = createMemo(() => (root.latest ?? []).length) const isMac = () => typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent) return (
onFilter(e.currentTarget.value)} placeholder="filter files…" style={{ all: "unset", flex: 1, "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} /> {totalCount()} entries
0} fallback={ loading…
} >
No files here
} >
Can't read this folder
{permissionError()}}> Grant Full Disk Access to OpenScience in System Settings → Privacy & Security, then refresh.
Open System Settings
} > {(node) => ( { setExpanded((prev) => { const next = new Set(prev) if (next.has(p)) next.delete(p) else next.add(p) return next }) }} onOpen={props.onOpen} filter={query} /> )} ) } function Node(props: { node: FileNode depth: number directory: string expanded: () => Set onToggle: (path: string) => void onOpen?: (path: string) => void filter: () => string }): JSX.Element { const sdk = useSDK() const isOpen = () => props.expanded().has(props.node.path) const e = ext(props.node.name) const matches = () => { const q = props.filter().toLowerCase().trim() if (!q) return true return props.node.name.toLowerCase().includes(q) || props.node.path.toLowerCase().includes(q) } const color = () => props.node.type === "directory" ? "var(--color-text)" : (EXT_COLOR[e] ?? "var(--color-text-muted)") const [children] = createResource( () => (isOpen() && props.node.type === "directory" ? props.node.path : null), async (path) => { if (!path) return null try { const res: any = await sdk.client.file.list({ directory: props.directory, path, } as any) const data = res?.data ?? res if (Array.isArray(data)) return data as FileNode[] } catch {} return [] as FileNode[] }, ) const childRows = createMemo(() => sortNodes(children.latest ?? [])) const dimmed = () => props.node.ignored || !matches() return (
loading…
0}> {(child) => ( )}
) }