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 { centerTabs } from "@/thesis/store/centerTabs"import { thesisAPI, type ThesisNode } from "@/thesis/api/thesis"import { IconFolder, IconFile, IconRefresh, IconSearch, IconChevronLeft, IconChevronDown, IconArrowUp, IconLayoutGrid, IconHome, IconCpu, IconArchive,} from "@/thesis/shared/Icon" interface FileNode { name: string path: string absolute: string type: "file" | "directory" ignored: boolean size?: number mtime?: number} const EXT_COLOR: Record<string, string> = { 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", rs: "#CE412B", go: "#00ADD8", pdf: "#E5484D", tex: "#3D6117", png: "#8B5CF6", jpg: "#8B5CF6", jpeg: "#8B5CF6", svg: "#E34F26",} const ext = (name: string): string => { const i = name.lastIndexOf(".") return i > 0 ? name.slice(i + 1).toLowerCase() : ""} const parentOf = (p: string): string => { const trimmed = p.replace(/\/+$/, "") const i = trimmed.lastIndexOf("/") if (i <= 0) return "/" return trimmed.slice(0, i)} function formatSize(bytes?: number): string { if (bytes === undefined) return "" if (bytes < 1024) return `${bytes} B` const units = ["KB", "MB", "GB", "TB"] let val = bytes / 1024 let u = 0 while (val >= 1024 && u < units.length - 1) { val /= 1024 u++ } return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[u]}`} function relativeTime(mtime?: number): string { if (!mtime) return "" const diff = Date.now() - mtime const s = Math.round(diff / 1000) if (s < 60) return "now" const m = Math.round(s / 60) if (m < 60) return `${m}m ago` const h = Math.round(m / 60) if (h < 24) return `${h}h ago` const d = Math.round(h / 24) if (d < 30) return `${d}d ago` const mo = Math.round(d / 30) if (mo < 12) return `${mo}mo ago` return `${Math.round(mo / 12)}y ago`} 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) })} function ListGlyph(props: { size?: number }): JSX.Element { const s = props.size ?? 12 return ( <svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> <line x1="5" y1="4" x2="14" y2="4" /> <line x1="5" y1="8" x2="14" y2="8" /> <line x1="5" y1="12" x2="14" y2="12" /> <circle cx="2.5" cy="4" r="0.9" fill="currentColor" stroke="none" /> <circle cx="2.5" cy="8" r="0.9" fill="currentColor" stroke="none" /> <circle cx="2.5" cy="12" r="0.9" fill="currentColor" stroke="none" /> </svg> )} /** * Host file explorer. Navigates real directories via sdk.client.file.list * (the `directory` query param re-roots the backend Instance, so any absolute * path is browsable). Clicking a folder navigates in; clicking a file opens a * center-pane document tab. An "artifacts" toggle swaps in the project's * Atlas artifacts instead of the host filesystem. */export function FileExplorer(): JSX.Element { const sdk = useSDK() const sync = useSync() const projectRoot = () => sync.project?.worktree || sync.data.path.directory || sdk.directory const home = () => sync.data.path.home || projectRoot() const [cwd, setCwd] = createSignal(projectRoot()) const [history, setHistory] = createSignal<string[]>([]) const [filter, setFilter] = createSignal("") // Debounced copy of `filter` that actually drives the (heavier) list filter, // so sorting/filtering the directory doesn't rerun on every keystroke. const [query, setQuery] = createSignal("") let filterTimer: ReturnType<typeof setTimeout> | undefined const setFilterDebounced = (v: string) => { setFilter(v) clearTimeout(filterTimer) filterTimer = setTimeout(() => setQuery(v), 110) } const clearFilter = () => { clearTimeout(filterTimer) setFilter("") setQuery("") } onCleanup(() => clearTimeout(filterTimer)) const [view, setView] = createSignal<"list" | "grid">("list") const [mode, setMode] = createSignal<"host" | "artifacts">("host") const [pathDraft, setPathDraft] = createSignal(cwd()) const [refreshKey, setRefreshKey] = createSignal(0) const [machineMenu, setMachineMenu] = createSignal(false) const [permissionError, setPermissionError] = createSignal<string | null>(null) // Keep the editable path bar synced to the current directory unless the user // is typing (they focus the input → onFocus stops the sync-through). let editing = false const navigate = (dir: string, push = true) => { const target = dir || "/" if (push && target !== cwd()) setHistory((h) => [...h, cwd()]) setCwd(target) setPathDraft(target) clearFilter() } const goBack = () => { const h = history() if (!h.length) return const prev = h[h.length - 1] setHistory(h.slice(0, -1)) setCwd(prev) setPathDraft(prev) clearFilter() } const goUp = () => navigate(parentOf(cwd())) const [entries] = createResource( () => [cwd(), refreshKey(), mode()] as const, async ([dir, , m]) => { if (m !== "host") return [] as FileNode[] setPermissionError(null) if (!dir) return [] as FileNode[] try { // Pass the params FLAT — the generated client maps `directory`/`path` // into the query string via buildClientParams; a `{ query: {...} }` // wrapper is silently dropped (no key matches), which sends neither // param and 400s. `directory` re-roots the backend Instance, letting us // browse any host directory (see server middleware + File.list). const res: any = await sdk.client.file.list({ directory: dir, path: "." }) const data = res?.data ?? res return Array.isArray(data) ? (data as FileNode[]) : [] } catch (err: any) { // throwOnError surfaces the response body (a plain-text HTTPException // message on macOS: "permission denied reading … — grant Full Disk // Access"). Detect that so the SPA can prompt for FDA instead of // silently showing an empty folder. const msg = String(err?.body?.message ?? err?.message ?? (typeof err === "string" ? err : "") ?? "") const status = err?.response?.status ?? err?.status ?? err?.statusCode if (status === 403 || /permission denied|full disk access/i.test(msg)) { setPermissionError(msg || "OpenScience cannot read this directory") } return [] as FileNode[] } }, ) // Sort once per directory load (stale-while-revalidate: `entries.latest` // keeps the previous folder's rows on screen while the next one fetches, so // navigating never blanks to an empty "loading…" flash). Filtering then runs // over the already-sorted list against the debounced query. const sorted = createMemo(() => sortNodes(entries.latest ?? [])) const filtered = createMemo(() => { const q = query().toLowerCase().trim() const rows = sorted() if (!q) return rows return rows.filter((n) => n.name.toLowerCase().includes(q)) }) const openFile = (node: FileNode) => centerTabs.openFile(cwd(), node.name) const onRowClick = (node: FileNode) => { if (node.type === "directory") navigate(node.absolute) else openFile(node) } const machineLabel = () => { const segs = home().split("/").filter(Boolean) return segs[segs.length - 1] || "This computer" } return ( <div style={{ flex: 1, "min-height": 0, "min-width": 0, display: "flex", "flex-direction": "column", overflow: "hidden", }} > {/* toolbar row 1: machine + toggles */} <div style={{ display: "flex", "align-items": "center", gap: "8px", padding: "10px 12px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <div style={{ position: "relative" }}> <button type="button" onClick={() => setMachineMenu((v) => !v)} style={machineBtn()}> <IconCpu size={13} strokeWidth={1.5} /> <span style={{ display: "flex", "flex-direction": "column", "line-height": 1.15, "text-align": "left" }}> <span style={{ "font-size": "11px", color: "var(--color-text)" }}>This computer</span> <span style={{ "font-size": "10px", color: "var(--color-text-faint)" }}>{machineLabel()}</span> </span> <IconChevronDown size={11} strokeWidth={1.5} /> </button> <Show when={machineMenu()}> <div onMouseLeave={() => setMachineMenu(false)} style={menuCard()}> <button type="button" style={menuRow()} onClick={() => { navigate(home()) setMachineMenu(false) }} > <IconHome size={12} strokeWidth={1.5} /> <span style={{ flex: 1, "text-align": "left" }}>Home</span> </button> <button type="button" style={menuRow()} onClick={() => { navigate(projectRoot()) setMachineMenu(false) }} > <IconFolder size={12} strokeWidth={1.5} /> <span style={{ flex: 1, "text-align": "left" }}>Project root</span> </button> </div> </Show> </div> <span style={{ flex: 1 }} /> {/* host / artifacts pill toggle */} <div style={pill()}> <button type="button" style={pillBtn(mode() === "host")} onClick={() => setMode("host")}> <IconFolder size={11} strokeWidth={1.6} /> files </button> <button type="button" style={pillBtn(mode() === "artifacts")} onClick={() => setMode("artifacts")}> <IconArchive size={11} strokeWidth={1.6} /> artifacts </button> </div> {/* list / grid pill toggle */} <Show when={mode() === "host"}> <div style={pill()}> <button type="button" title="list" style={pillBtn(view() === "list")} onClick={() => setView("list")}> <ListGlyph size={12} /> </button> <button type="button" title="grid" style={pillBtn(view() === "grid")} onClick={() => setView("grid")}> <IconLayoutGrid size={12} strokeWidth={1.6} /> </button> </div> </Show> </div> <Show when={mode() === "host"} fallback={<ArtifactsPanel />}> {/* toolbar row 2: back / up / path bar / refresh */} <div style={{ display: "flex", "align-items": "center", gap: "6px", padding: "8px 12px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <button type="button" title="back" disabled={!history().length} style={navBtn(!history().length)} onClick={goBack} > <IconChevronLeft size={13} strokeWidth={1.6} /> </button> <button type="button" title="up" style={navBtn(false)} onClick={goUp}> <IconArrowUp size={13} strokeWidth={1.6} /> </button> <div style={pathBar()}> <input value={pathDraft()} spellcheck={false} onFocus={() => (editing = true)} onBlur={() => { editing = false setPathDraft(cwd()) }} onInput={(e) => setPathDraft(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { editing = false navigate(pathDraft().trim()) e.currentTarget.blur() } if (e.key === "Escape") { setPathDraft(cwd()) e.currentTarget.blur() } }} placeholder="/absolute/path" style={{ all: "unset", flex: 1, "min-width": 0, "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} /> </div> <button type="button" title="refresh" style={navBtn(false)} onClick={() => setRefreshKey((k) => k + 1)}> <IconRefresh size={12} strokeWidth={1.6} /> </button> </div> {/* search */} <div style={{ display: "flex", "align-items": "center", gap: "6px", padding: "8px 14px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <IconSearch size={11} strokeWidth={1.5} /> <input value={filter()} onInput={(e) => setFilterDebounced(e.currentTarget.value)} placeholder="filter this folder…" style={{ all: "unset", flex: 1, "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} /> <span style={{ "font-family": FONT_MONO, "font-size": "10px", color: "var(--color-text-faint)" }}> {filtered().length} items </span> </div> {/* body */} <div class="thesis-scroll" style={{ flex: 1, "min-height": 0, "overflow-y": "auto", "overflow-x": "hidden" }}> <Show when={!entries.loading || entries.latest} fallback={<div style={emptyMsg()}>loading…</div>}> <Show when={!permissionError()} fallback={ <div style={{ display: "flex", "flex-direction": "column", "align-items": "center", gap: "8px", padding: "40px 22px", "text-align": "center", }} > <IconFolder size={20} strokeWidth={1.4} /> <div style={{ "font-family": FONT_SANS, "font-size": "13px", "font-weight": 500, color: "var(--color-text)", }} > Can't read this folder </div> <div style={{ "font-family": FONT_SANS, "font-size": "12px", color: "var(--color-text-faint)", "line-height": 1.5, "max-width": "320px", }} > {permissionError()} </div> <button type="button" onClick={() => setRefreshKey((k) => k + 1)} style={{ all: "unset", cursor: "pointer", "margin-top": "2px", padding: "5px 12px", "border-radius": "4px", border: "1px solid var(--color-border)", "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} > retry </button> </div> } > <Show when={filtered().length > 0} fallback={<div style={emptyMsg()}>empty folder</div>}> <Show when={view() === "list"} fallback={<GridBody nodes={filtered()} onClick={onRowClick} />}> <ListBody nodes={filtered()} onClick={onRowClick} /> </Show> </Show> </Show> </Show> </div> </Show> </div> )} function ListBody(props: { nodes: FileNode[]; onClick: (n: FileNode) => void }): JSX.Element { return ( <div> <div style={colHeader()}> <span style={{ flex: 1 }}>Name</span> <span style={{ width: "78px", "text-align": "right" }}>Size</span> <span style={{ width: "78px", "text-align": "right" }}>Modified</span> </div> <For each={props.nodes}> {(node) => { const c = node.type === "directory" ? "var(--color-text)" : (EXT_COLOR[ext(node.name)] ?? "var(--color-text-muted)") return ( <button type="button" onClick={() => props.onClick(node)} title={node.absolute} style={row(node.ignored)} onMouseEnter={(el) => (el.currentTarget.style.background = "var(--color-accent-subtle)")} onMouseLeave={(el) => (el.currentTarget.style.background = "transparent")} > <span style={{ display: "inline-flex", color: c, "flex-shrink": 0 }}> <Show when={node.type === "directory"} fallback={<IconFile size={13} strokeWidth={1.5} />}>Lines 1–500 of 841. Load more
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 { centerTabs } from "@/thesis/store/centerTabs" import { thesisAPI, type ThesisNode } from "@/thesis/api/thesis" import { IconFolder, IconFile, IconRefresh, IconSearch, IconChevronLeft, IconChevronDown, IconArrowUp, IconLayoutGrid, IconHome, IconCpu, IconArchive, } from "@/thesis/shared/Icon" interface FileNode { name: string path: string absolute: string type: "file" | "directory" ignored: boolean size?: number mtime?: number } const EXT_COLOR: Record<string, string> = { 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", rs: "#CE412B", go: "#00ADD8", pdf: "#E5484D", tex: "#3D6117", png: "#8B5CF6", jpg: "#8B5CF6", jpeg: "#8B5CF6", svg: "#E34F26", } const ext = (name: string): string => { const i = name.lastIndexOf(".") return i > 0 ? name.slice(i + 1).toLowerCase() : "" } const parentOf = (p: string): string => { const trimmed = p.replace(/\/+$/, "") const i = trimmed.lastIndexOf("/") if (i <= 0) return "/" return trimmed.slice(0, i) } function formatSize(bytes?: number): string { if (bytes === undefined) return "" if (bytes < 1024) return `${bytes} B` const units = ["KB", "MB", "GB", "TB"] let val = bytes / 1024 let u = 0 while (val >= 1024 && u < units.length - 1) { val /= 1024 u++ } return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[u]}` } function relativeTime(mtime?: number): string { if (!mtime) return "" const diff = Date.now() - mtime const s = Math.round(diff / 1000) if (s < 60) return "now" const m = Math.round(s / 60) if (m < 60) return `${m}m ago` const h = Math.round(m / 60) if (h < 24) return `${h}h ago` const d = Math.round(h / 24) if (d < 30) return `${d}d ago` const mo = Math.round(d / 30) if (mo < 12) return `${mo}mo ago` return `${Math.round(mo / 12)}y ago` } 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) }) } function ListGlyph(props: { size?: number }): JSX.Element { const s = props.size ?? 12 return ( <svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> <line x1="5" y1="4" x2="14" y2="4" /> <line x1="5" y1="8" x2="14" y2="8" /> <line x1="5" y1="12" x2="14" y2="12" /> <circle cx="2.5" cy="4" r="0.9" fill="currentColor" stroke="none" /> <circle cx="2.5" cy="8" r="0.9" fill="currentColor" stroke="none" /> <circle cx="2.5" cy="12" r="0.9" fill="currentColor" stroke="none" /> </svg> ) } /** * Host file explorer. Navigates real directories via sdk.client.file.list * (the `directory` query param re-roots the backend Instance, so any absolute * path is browsable). Clicking a folder navigates in; clicking a file opens a * center-pane document tab. An "artifacts" toggle swaps in the project's * Atlas artifacts instead of the host filesystem. */ export function FileExplorer(): JSX.Element { const sdk = useSDK() const sync = useSync() const projectRoot = () => sync.project?.worktree || sync.data.path.directory || sdk.directory const home = () => sync.data.path.home || projectRoot() const [cwd, setCwd] = createSignal(projectRoot()) const [history, setHistory] = createSignal<string[]>([]) const [filter, setFilter] = createSignal("") // Debounced copy of `filter` that actually drives the (heavier) list filter, // so sorting/filtering the directory doesn't rerun on every keystroke. const [query, setQuery] = createSignal("") let filterTimer: ReturnType<typeof setTimeout> | undefined const setFilterDebounced = (v: string) => { setFilter(v) clearTimeout(filterTimer) filterTimer = setTimeout(() => setQuery(v), 110) } const clearFilter = () => { clearTimeout(filterTimer) setFilter("") setQuery("") } onCleanup(() => clearTimeout(filterTimer)) const [view, setView] = createSignal<"list" | "grid">("list") const [mode, setMode] = createSignal<"host" | "artifacts">("host") const [pathDraft, setPathDraft] = createSignal(cwd()) const [refreshKey, setRefreshKey] = createSignal(0) const [machineMenu, setMachineMenu] = createSignal(false) const [permissionError, setPermissionError] = createSignal<string | null>(null) // Keep the editable path bar synced to the current directory unless the user // is typing (they focus the input → onFocus stops the sync-through). let editing = false const navigate = (dir: string, push = true) => { const target = dir || "/" if (push && target !== cwd()) setHistory((h) => [...h, cwd()]) setCwd(target) setPathDraft(target) clearFilter() } const goBack = () => { const h = history() if (!h.length) return const prev = h[h.length - 1] setHistory(h.slice(0, -1)) setCwd(prev) setPathDraft(prev) clearFilter() } const goUp = () => navigate(parentOf(cwd())) const [entries] = createResource( () => [cwd(), refreshKey(), mode()] as const, async ([dir, , m]) => { if (m !== "host") return [] as FileNode[] setPermissionError(null) if (!dir) return [] as FileNode[] try { // Pass the params FLAT — the generated client maps `directory`/`path` // into the query string via buildClientParams; a `{ query: {...} }` // wrapper is silently dropped (no key matches), which sends neither // param and 400s. `directory` re-roots the backend Instance, letting us // browse any host directory (see server middleware + File.list). const res: any = await sdk.client.file.list({ directory: dir, path: "." }) const data = res?.data ?? res return Array.isArray(data) ? (data as FileNode[]) : [] } catch (err: any) { // throwOnError surfaces the response body (a plain-text HTTPException // message on macOS: "permission denied reading … — grant Full Disk // Access"). Detect that so the SPA can prompt for FDA instead of // silently showing an empty folder. const msg = String(err?.body?.message ?? err?.message ?? (typeof err === "string" ? err : "") ?? "") const status = err?.response?.status ?? err?.status ?? err?.statusCode if (status === 403 || /permission denied|full disk access/i.test(msg)) { setPermissionError(msg || "OpenScience cannot read this directory") } return [] as FileNode[] } }, ) // Sort once per directory load (stale-while-revalidate: `entries.latest` // keeps the previous folder's rows on screen while the next one fetches, so // navigating never blanks to an empty "loading…" flash). Filtering then runs // over the already-sorted list against the debounced query. const sorted = createMemo(() => sortNodes(entries.latest ?? [])) const filtered = createMemo(() => { const q = query().toLowerCase().trim() const rows = sorted() if (!q) return rows return rows.filter((n) => n.name.toLowerCase().includes(q)) }) const openFile = (node: FileNode) => centerTabs.openFile(cwd(), node.name) const onRowClick = (node: FileNode) => { if (node.type === "directory") navigate(node.absolute) else openFile(node) } const machineLabel = () => { const segs = home().split("/").filter(Boolean) return segs[segs.length - 1] || "This computer" } return ( <div style={{ flex: 1, "min-height": 0, "min-width": 0, display: "flex", "flex-direction": "column", overflow: "hidden", }} > {/* toolbar row 1: machine + toggles */} <div style={{ display: "flex", "align-items": "center", gap: "8px", padding: "10px 12px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <div style={{ position: "relative" }}> <button type="button" onClick={() => setMachineMenu((v) => !v)} style={machineBtn()}> <IconCpu size={13} strokeWidth={1.5} /> <span style={{ display: "flex", "flex-direction": "column", "line-height": 1.15, "text-align": "left" }}> <span style={{ "font-size": "11px", color: "var(--color-text)" }}>This computer</span> <span style={{ "font-size": "10px", color: "var(--color-text-faint)" }}>{machineLabel()}</span> </span> <IconChevronDown size={11} strokeWidth={1.5} /> </button> <Show when={machineMenu()}> <div onMouseLeave={() => setMachineMenu(false)} style={menuCard()}> <button type="button" style={menuRow()} onClick={() => { navigate(home()) setMachineMenu(false) }} > <IconHome size={12} strokeWidth={1.5} /> <span style={{ flex: 1, "text-align": "left" }}>Home</span> </button> <button type="button" style={menuRow()} onClick={() => { navigate(projectRoot()) setMachineMenu(false) }} > <IconFolder size={12} strokeWidth={1.5} /> <span style={{ flex: 1, "text-align": "left" }}>Project root</span> </button> </div> </Show> </div> <span style={{ flex: 1 }} /> {/* host / artifacts pill toggle */} <div style={pill()}> <button type="button" style={pillBtn(mode() === "host")} onClick={() => setMode("host")}> <IconFolder size={11} strokeWidth={1.6} /> files </button> <button type="button" style={pillBtn(mode() === "artifacts")} onClick={() => setMode("artifacts")}> <IconArchive size={11} strokeWidth={1.6} /> artifacts </button> </div> {/* list / grid pill toggle */} <Show when={mode() === "host"}> <div style={pill()}> <button type="button" title="list" style={pillBtn(view() === "list")} onClick={() => setView("list")}> <ListGlyph size={12} /> </button> <button type="button" title="grid" style={pillBtn(view() === "grid")} onClick={() => setView("grid")}> <IconLayoutGrid size={12} strokeWidth={1.6} /> </button> </div> </Show> </div> <Show when={mode() === "host"} fallback={<ArtifactsPanel />}> {/* toolbar row 2: back / up / path bar / refresh */} <div style={{ display: "flex", "align-items": "center", gap: "6px", padding: "8px 12px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <button type="button" title="back" disabled={!history().length} style={navBtn(!history().length)} onClick={goBack} > <IconChevronLeft size={13} strokeWidth={1.6} /> </button> <button type="button" title="up" style={navBtn(false)} onClick={goUp}> <IconArrowUp size={13} strokeWidth={1.6} /> </button> <div style={pathBar()}> <input value={pathDraft()} spellcheck={false} onFocus={() => (editing = true)} onBlur={() => { editing = false setPathDraft(cwd()) }} onInput={(e) => setPathDraft(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { editing = false navigate(pathDraft().trim()) e.currentTarget.blur() } if (e.key === "Escape") { setPathDraft(cwd()) e.currentTarget.blur() } }} placeholder="/absolute/path" style={{ all: "unset", flex: 1, "min-width": 0, "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} /> </div> <button type="button" title="refresh" style={navBtn(false)} onClick={() => setRefreshKey((k) => k + 1)}> <IconRefresh size={12} strokeWidth={1.6} /> </button> </div> {/* search */} <div style={{ display: "flex", "align-items": "center", gap: "6px", padding: "8px 14px", "border-bottom": "1px solid var(--color-border)", "flex-shrink": 0, }} > <IconSearch size={11} strokeWidth={1.5} /> <input value={filter()} onInput={(e) => setFilterDebounced(e.currentTarget.value)} placeholder="filter this folder…" style={{ all: "unset", flex: 1, "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} /> <span style={{ "font-family": FONT_MONO, "font-size": "10px", color: "var(--color-text-faint)" }}> {filtered().length} items </span> </div> {/* body */} <div class="thesis-scroll" style={{ flex: 1, "min-height": 0, "overflow-y": "auto", "overflow-x": "hidden" }}> <Show when={!entries.loading || entries.latest} fallback={<div style={emptyMsg()}>loading…</div>}> <Show when={!permissionError()} fallback={ <div style={{ display: "flex", "flex-direction": "column", "align-items": "center", gap: "8px", padding: "40px 22px", "text-align": "center", }} > <IconFolder size={20} strokeWidth={1.4} /> <div style={{ "font-family": FONT_SANS, "font-size": "13px", "font-weight": 500, color: "var(--color-text)", }} > Can't read this folder </div> <div style={{ "font-family": FONT_SANS, "font-size": "12px", color: "var(--color-text-faint)", "line-height": 1.5, "max-width": "320px", }} > {permissionError()} </div> <button type="button" onClick={() => setRefreshKey((k) => k + 1)} style={{ all: "unset", cursor: "pointer", "margin-top": "2px", padding: "5px 12px", "border-radius": "4px", border: "1px solid var(--color-border)", "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text)", }} > retry </button> </div> } > <Show when={filtered().length > 0} fallback={<div style={emptyMsg()}>empty folder</div>}> <Show when={view() === "list"} fallback={<GridBody nodes={filtered()} onClick={onRowClick} />}> <ListBody nodes={filtered()} onClick={onRowClick} /> </Show> </Show> </Show> </Show> </div> </Show> </div> ) } function ListBody(props: { nodes: FileNode[]; onClick: (n: FileNode) => void }): JSX.Element { return ( <div> <div style={colHeader()}> <span style={{ flex: 1 }}>Name</span> <span style={{ width: "78px", "text-align": "right" }}>Size</span> <span style={{ width: "78px", "text-align": "right" }}>Modified</span> </div> <For each={props.nodes}> {(node) => { const c = node.type === "directory" ? "var(--color-text)" : (EXT_COLOR[ext(node.name)] ?? "var(--color-text-muted)") return ( <button type="button" onClick={() => props.onClick(node)} title={node.absolute} style={row(node.ignored)} onMouseEnter={(el) => (el.currentTarget.style.background = "var(--color-accent-subtle)")} onMouseLeave={(el) => (el.currentTarget.style.background = "transparent")} > <span style={{ display: "inline-flex", color: c, "flex-shrink": 0 }}> <Show when={node.type === "directory"} fallback={<IconFile size={13} strokeWidth={1.5} />}>