import { h, Fragment } from "preact" import { useEffect, useMemo, useRef, useState } from "preact/hooks" import { fetchFiles, fetchFile, putFile, deleteFile, moveFile, build, fetchEnv, putEnv, deleteEnvKey, streamLogs, type FileEntry, type BuildResult, type LogEntry, } from "./api.js" import { CodeMirrorEditor } from "./Editor.js" interface BootstrapLastBuild { bundleBytes: number bundleHash: string builtAt: string durationMs: number } declare global { interface Window { __POND_IDE?: { deployId: string deployUrl: string publicHost: string lastBuild?: BootstrapLastBuild | null } } } interface Bootstrap { deployId: string deployUrl: string publicHost: string lastBuild: BootstrapLastBuild | null } function readBootstrap(): Bootstrap { if (typeof window !== "undefined" && window.__POND_IDE) { return { deployId: window.__POND_IDE.deployId, deployUrl: window.__POND_IDE.deployUrl, publicHost: window.__POND_IDE.publicHost, lastBuild: window.__POND_IDE.lastBuild ?? null, } } return { deployId: "", deployUrl: "", publicHost: "", lastBuild: null } } interface TokenInfo { token: string isClaim: boolean } function readTokenFromHash(): TokenInfo | null { if (typeof window === "undefined") return null const h = window.location.hash.slice(1) if (!h) return null const params = new URLSearchParams(h) const claim = params.get("token") || params.get("claim") if (claim) return { token: claim, isClaim: true } const bearer = params.get("bearer") if (bearer) return { token: bearer, isClaim: false } return null } function tokenKey(deployId: string): string { return `pond-ide-token:${deployId}` } // Anonymous claim tokens live in sessionStorage (cleared on tab close) because // they are bearer-like secrets that the holder can use to take the deploy. // User bearer tokens stay in localStorage so signed-in users don't have to // re-paste a bearer every time they re-open the tab — a leaked bearer can be // rotated via `pond token`, but a leaked claim token cannot be rotated and // (pre-0.3.9 server) could be used to dispossess the current owner. function storageFor(info: TokenInfo): Storage { return info.isClaim ? window.sessionStorage : window.localStorage } function loadStoredToken(deployId: string): TokenInfo | null { for (const store of [window.sessionStorage, window.localStorage]) { try { const raw = store.getItem(tokenKey(deployId)) if (raw) return JSON.parse(raw) as TokenInfo } catch { // continue } } return null } function storeToken(deployId: string, info: TokenInfo) { // Write to the appropriate store, and clear the other one so we don't end // up with a stale claim token in localStorage from a pre-0.3.9 session. try { storageFor(info).setItem(tokenKey(deployId), JSON.stringify(info)) } catch {} try { const otherStore = info.isClaim ? window.localStorage : window.sessionStorage otherStore.removeItem(tokenKey(deployId)) } catch {} } function clearStoredToken(deployId: string) { try { window.sessionStorage.removeItem(tokenKey(deployId)) } catch {} try { window.localStorage.removeItem(tokenKey(deployId)) } catch {} } interface Outline { tables: string[] queries: string[] mutations: string[] } function parseOutline(src: string): Outline { const grab = (label: string): string[] => { const re = new RegExp(`${label}\\s*:\\s*\\{`, "m") const m = re.exec(src) if (!m) return [] let i = m.index + m[0].length let depth = 1 let body = "" while (i < src.length && depth > 0) { const ch = src[i] if (ch === "{") depth++ else if (ch === "}") { depth-- if (depth === 0) break } body += ch i++ } const names: string[] = [] const ident = /(?:^|[,{\s])\s*([a-zA-Z_$][\w$]*)\s*:/g let nm: RegExpExecArray | null while ((nm = ident.exec(body))) names.push(nm[1]) return names } return { tables: grab("schema"), queries: grab("queries"), mutations: grab("mutations") } } const REQUIRED_PATHS = new Set(["server/index.ts", "package.json"]) export function App() { const bootstrap = useMemo(readBootstrap, []) const [token, setToken] = useState(() => { if (!bootstrap.deployId) return null const fromHash = readTokenFromHash() if (fromHash) { storeToken(bootstrap.deployId, fromHash) try { history.replaceState({}, "", window.location.pathname) } catch {} return fromHash } return loadStoredToken(bootstrap.deployId) }) if (!bootstrap.deployId) { return } if (!token) { return ( { storeToken(bootstrap.deployId, info) setToken(info) }} /> ) } return ( { clearStoredToken(bootstrap.deployId) setToken(null) }} /> ) } function FatalMessage({ title, detail }: { title: string; detail: string }) { return (

{title}

{detail}

) } function TokenGate({ onSubmit }: { onSubmit: (info: TokenInfo) => void }) { const [token, setToken] = useState("") const [kind, setKind] = useState<"claim" | "bearer">("claim") return (
{ e.preventDefault() if (token.trim()) onSubmit({ token: token.trim(), isClaim: kind === "claim" }) }} >

Open this capsule in the IDE

Paste your claim token (anonymous deploy owner) or your account API token.

setToken((e.target as HTMLInputElement).value)} />
) } interface WorkspaceProps { bootstrap: Bootstrap token: TokenInfo onSignOut: () => void } function Workspace({ bootstrap, token, onSignOut }: WorkspaceProps) { const opts = useMemo( () => ({ deployId: bootstrap.deployId, token: token.token, isClaim: token.isClaim }), [bootstrap.deployId, token], ) const [files, setFiles] = useState([]) const [activePath, setActivePath] = useState(null) const [openTabs, setOpenTabs] = useState([]) const [contents, setContents] = useState>({}) const [loadError, setLoadError] = useState(null) const [building, setBuilding] = useState(false) const [lastBuild, setLastBuild] = useState(() => bootstrap.lastBuild ? { ok: true, bundleBytes: bootstrap.lastBuild.bundleBytes, bundleHash: bootstrap.lastBuild.bundleHash, durationMs: bootstrap.lastBuild.durationMs, } : null, ) const [previewKey, setPreviewKey] = useState(0) const [showPreview, setShowPreview] = useState(true) const [rightTab, setRightTab] = useState<"preview" | "logs" | "env">("preview") const [paletteOpen, setPaletteOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) const [diffOpen, setDiffOpen] = useState(false) // Load file list on mount / token change. useEffect(() => { let cancelled = false fetchFiles(opts).then((res) => { if (cancelled) return if ("error" in res) { setLoadError(`${res.error} (HTTP ${res.status})`) if (res.status === 401 || res.status === 403) onSignOut() return } setFiles(res.files) const seed = res.files.find((f) => f.path === "server/index.ts") ?? res.files[0] if (seed && !activePath) void openFile(seed.path) }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [opts.deployId, opts.token]) async function openFile(path: string) { if (!(path in contents)) { const r = await fetchFile(opts, path) if ("error" in r) { setLoadError(r.error) return } setContents((c) => ({ ...c, [path]: { saved: r.text, draft: r.text } })) } setOpenTabs((tabs) => (tabs.includes(path) ? tabs : [...tabs, path])) setActivePath(path) } function closeTab(path: string) { setOpenTabs((tabs) => tabs.filter((t) => t !== path)) setActivePath((cur) => { if (cur !== path) return cur const remaining = openTabs.filter((t) => t !== path) return remaining.length ? remaining[remaining.length - 1] : null }) } async function saveFile(path: string): Promise { const entry = contents[path] if (!entry) return true if (entry.draft === entry.saved) return true const r = await putFile(opts, path, entry.draft) if ("error" in r) { setLoadError(r.error) return false } setContents((c) => ({ ...c, [path]: { saved: entry.draft, draft: entry.draft } })) return true } async function saveAllDirty(): Promise { for (const [p, v] of Object.entries(contents)) { if (v.saved !== v.draft) { const ok = await saveFile(p) if (!ok) return false } } return true } async function refreshFiles() { const res = await fetchFiles(opts) if ("error" in res) return setFiles(res.files) } async function handleDeploy() { setBuilding(true) try { const ok = await saveAllDirty() if (!ok) return const result = await build(opts) setLastBuild(result) if (result.ok) { setPreviewKey((k) => k + 1) await refreshFiles() } } finally { setBuilding(false) } } async function handleNewFile() { const path = window.prompt("New file path (e.g. shared/notes.md or client/header.tsx)") if (!path) return const r = await putFile(opts, path.trim(), "") if ("error" in r) { setLoadError(r.error) return } await refreshFiles() await openFile(path.trim()) } async function handleDelete(path: string) { if (REQUIRED_PATHS.has(path)) return if (!window.confirm(`Delete ${path}?`)) return const r = await deleteFile(opts, path) if ("error" in r) { setLoadError(r.error) return } setContents((c) => { const next = { ...c } delete next[path] return next }) closeTab(path) await refreshFiles() } async function handleRename(path: string) { if (REQUIRED_PATHS.has(path)) return const next = window.prompt(`Rename ${path} to:`, path) if (!next || next === path) return const r = await moveFile(opts, path, next.trim()) if ("error" in r) { setLoadError(r.error) return } setContents((c) => { const v = c[path] if (!v) return c const cn = { ...c, [next.trim()]: v } delete cn[path] return cn }) setOpenTabs((tabs) => tabs.map((t) => (t === path ? next.trim() : t))) if (activePath === path) setActivePath(next.trim()) await refreshFiles() } const dirty = useMemo(() => { const out = new Set() for (const [p, v] of Object.entries(contents)) if (v.saved !== v.draft) out.add(p) return out }, [contents]) useEffect(() => { const onKey = (e: KeyboardEvent) => { const meta = e.metaKey || e.ctrlKey if (meta && !e.shiftKey && e.key.toLowerCase() === "k") { e.preventDefault() setPaletteOpen(true) } else if (meta && e.shiftKey && e.key.toLowerCase() === "f") { e.preventDefault() setSearchOpen(true) } else if (e.key === "Escape") { setPaletteOpen(false) setSearchOpen(false) setDiffOpen(false) } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, []) function attemptDeploy() { if (dirty.size > 0) setDiffOpen(true) else void handleDeploy() } const outline = useMemo(() => parseOutline(contents["server/index.ts"]?.draft ?? ""), [contents]) const bundleBytes = lastBuild?.ok ? lastBuild.bundleBytes : null const totalSize = useMemo(() => files.reduce((n, f) => n + f.size, 0), [files]) return (
setPaletteOpen(true)} />
void openFile(p)} onDelete={(p) => void handleDelete(p)} onRename={(p) => void handleRename(p)} onNewFile={() => void handleNewFile()} /> setContents((c) => ({ ...c, [p]: { ...c[p], draft: next } }))} onSave={(p) => void saveFile(p)} onPickTab={(p) => setActivePath(p)} onCloseTab={closeTab} /> setShowPreview((s) => !s)} activeTab={rightTab} onTab={setRightTab} apiOpts={opts} />
{paletteOpen ? ( setPaletteOpen(false)} onOpenFile={(p) => { void openFile(p) setPaletteOpen(false) }} onDeploy={() => { setPaletteOpen(false) attemptDeploy() }} onTogglePreview={() => { setShowPreview((s) => !s) setPaletteOpen(false) }} onSearch={() => { setPaletteOpen(false) setSearchOpen(true) }} onSignOut={() => { setPaletteOpen(false) onSignOut() }} /> ) : null} {searchOpen ? ( setSearchOpen(false)} onOpenFile={(p) => { void openFile(p) setSearchOpen(false) }} onPrefetch={async (path) => { if (!(path in contents)) { const r = await fetchFile(opts, path) if (!("error" in r)) setContents((c) => ({ ...c, [path]: { saved: r.text, draft: r.text } })) } }} files={files} /> ) : null} {diffOpen ? ( setDiffOpen(false)} onConfirm={async () => { setDiffOpen(false) await handleDeploy() }} /> ) : null} {loadError ? (
{loadError}
) : null}
) } interface HeaderProps { deployId: string deployUrl: string bundleBytes: number | null totalSourceBytes: number dirtyCount: number building: boolean onDeploy: () => void onSignOut: () => void onOpenPalette: () => void } function Header(p: HeaderProps) { return (

pond IDE

{p.deployId} {" "} · source {(p.totalSourceBytes / 1024).toFixed(1)} KB {p.bundleBytes != null ? ` · bundle ${(p.bundleBytes / 1024).toFixed(1)} KB` : ""}

{p.dirtyCount > 0 ? ( {p.dirtyCount} unsaved ) : ( saved )}
) } interface FileTreeProps { files: FileEntry[] activePath: string | null dirty: Set onOpen: (p: string) => void onDelete: (p: string) => void onRename: (p: string) => void onNewFile: () => void } function FileTreePane(p: FileTreeProps) { const grouped = useMemo(() => { const g: Record = {} for (const f of p.files) { const root = f.path.includes("/") ? f.path.split("/")[0] : "." ;(g[root] ??= []).push(f) } return g }, [p.files]) const order = ["server", "client", "shared", "."] return ( ) } interface EditorPaneProps { openTabs: string[] activePath: string | null dirty: Set contents: Record onChange: (p: string, next: string) => void onSave: (p: string) => void onPickTab: (p: string) => void onCloseTab: (p: string) => void } function EditorPane(p: EditorPaneProps) { return (
{p.openTabs.length === 0 ? ( Open a file from the left ) : ( p.openTabs.map((path) => { const isActive = path === p.activePath return (
) }) )}
{p.activePath && p.contents[p.activePath] ? ( p.onChange(p.activePath as string, next)} onSave={() => p.onSave(p.activePath as string)} /> ) : (
No file open
)}
) } interface RightPaneProps { outline: Outline lastBuild: BuildResult | null building: boolean deployUrl: string previewKey: number showPreview: boolean onTogglePreview: () => void activeTab: "preview" | "logs" | "env" onTab: (t: "preview" | "logs" | "env") => void apiOpts: { deployId: string; token: string; isClaim: boolean } } function RightPane(p: RightPaneProps) { return ( ) } function LogsSection({ deployUrl, apiOpts, }: { deployUrl: string apiOpts: { deployId: string; token: string; isClaim: boolean } }) { const [entries, setEntries] = useState([]) const [paused, setPaused] = useState(false) const [filter, setFilter] = useState<"all" | "info" | "error">("all") const [err, setErr] = useState(null) const scrollRef = useRef(null) const queuedRef = useRef([]) const pausedRef = useRef(paused) pausedRef.current = paused useEffect(() => { if (!deployUrl) return setEntries([]) setErr(null) queuedRef.current = [] const stop = streamLogs( deployUrl, apiOpts, (entry) => { if (pausedRef.current) { queuedRef.current.push(entry) return } setEntries((es) => [...es.slice(-499), entry]) }, (e) => setErr(e instanceof Error ? e.message : String(e)), ) return stop }, [deployUrl, apiOpts.deployId, apiOpts.token]) useEffect(() => { if (!paused && queuedRef.current.length) { const flushed = queuedRef.current queuedRef.current = [] setEntries((es) => [...es.slice(-(500 - flushed.length)), ...flushed]) } }, [paused]) useEffect(() => { if (paused) return const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight }, [entries, paused]) const filtered = filter === "all" ? entries : entries.filter((e) => e.level === filter) return (
setPaused(true)} onMouseLeave={() => setPaused(false)} >
Logs {paused ? "(paused)" : ""}
{(["all", "info", "error"] as const).map((f) => ( ))}
{err ?
{err}
: null} {filtered.length === 0 ?
No log entries yet.
: null} {filtered.map((e, i) => (
{e.timestamp.slice(11, 19)} {e.message} {e.data ? {JSON.stringify(e.data)} : null}
))}
) } function EnvSection({ apiOpts }: { apiOpts: { deployId: string; token: string; isClaim: boolean } }) { const [entries, setEntries] = useState>({}) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) const [editing, setEditing] = useState<{ key: string; value: string } | null>(null) const [newKey, setNewKey] = useState("") const [newValue, setNewValue] = useState("") useEffect(() => { let cancelled = false setLoading(true) void fetchEnv(apiOpts).then((res) => { if (cancelled) return if ("error" in res) { setErr(res.error) } else { setEntries(res.entries) } setLoading(false) }) return () => { cancelled = true } }, [apiOpts.deployId, apiOpts.token]) async function save(key: string, value: string) { setErr(null) const res = await putEnv(apiOpts, { [key]: value }) if ("error" in res) { setErr(res.error) return } setEntries(res.entries) } async function remove(key: string) { if (!confirm(`Delete ${key}?`)) return setErr(null) const res = await deleteEnvKey(apiOpts, key) if ("error" in res) { setErr(res.error) return } setEntries(res.entries) } function mask(v: string): string { if (v.length <= 4) return "•".repeat(v.length) return v.slice(0, 2) + "…" + v.slice(-2) } return (
Env (.env.pond.server)
{err ? (
{err}
) : null} {loading ?
Loading…
: null}
{Object.keys(entries) .sort() .map((k) => { const isEditing = editing?.key === k return (
{k} {isEditing ? ( setEditing({ key: k, value: (e.target as HTMLInputElement).value })} /> ) : ( {mask(entries[k])} )} {isEditing ? ( ) : ( )}
) })}
Add new
setNewKey((e.target as HTMLInputElement).value.toUpperCase())} /> setNewValue((e.target as HTMLInputElement).value)} />
) } function OutlineSection({ outline }: { outline: Outline }) { const totals = outline.tables.length + outline.queries.length + outline.mutations.length return (
Outline
Total
{totals}
) } function OutlineCell({ label, items }: { label: string; items: string[] }) { return (
{label}
{items.length}
{items.length ? (
{items.slice(0, 3).join(", ")} {items.length > 3 ? "…" : ""}
) : null}
) } function DiagnosticsSection({ lastBuild, building }: { lastBuild: BuildResult | null; building: boolean }) { return (
Diagnostics
{building ? (
Building…
) : !lastBuild ? (
No build yet. Hit Deploy to compile.
) : lastBuild.ok ? (
✓ Built {lastBuild.durationMs > 0 ? ` in ${lastBuild.durationMs}ms` : ""} ·{" "} {(lastBuild.bundleBytes / 1024).toFixed(1)} KB
) : (
    {lastBuild.errors.map((e) => (
  • {e.file ? ( {e.file} {e.line ? `:${e.line}` : ""} ) : null} {e.text}
  • ))}
)}
) } function PreviewSection({ deployUrl, previewKey, showPreview, onToggle, }: { deployUrl: string previewKey: number showPreview: boolean onToggle: () => void }) { return (
Preview
{showPreview && deployUrl ? (