/** * DbBrowserPanel — read-only SQLite inspector for the active project. * * Left pane: table list with row counts; right pane: column definitions * and a paginated row table. Mounted as a tab inside PreviewPanel. * * Schema and row fetches both go through /api/projects//db/... which * opens the DB read-only. Schema auto-refreshes on tab focus + every * REFRESH_INTERVAL_MS so writes from the running backend show up without * a manual reload. */ import { useEffect, useMemo, useState } from 'react' import useAppStore from '../store/appStore' const REFRESH_INTERVAL_MS = 6_000 export default function DbBrowserPanel() { const projectId = useAppStore((s) => s.project.id) const [schema, setSchema] = useState(null) const [activeTbl, setActiveTbl] = useState(null) const [rows, setRows] = useState(null) const [offset, setOffset] = useState(0) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const PAGE = 100 // Load schema (and re-poll while the panel is mounted so backend writes // become visible without explicit user action). useEffect(() => { if (!projectId) return let cancelled = false async function loadSchema() { try { const r = await fetch(`/api/projects/${projectId}/db/schema`) if (!r.ok) { if (!cancelled) { const err = await r.json().catch(() => ({})) setError(err.detail || `HTTP ${r.status}`) setSchema(null) } return } const data = await r.json() if (cancelled) return setError(null) setSchema(data) } catch (e) { if (!cancelled) setError(String(e.message || e)) } } loadSchema() const id = setInterval(loadSchema, REFRESH_INTERVAL_MS) return () => { cancelled = true; clearInterval(id) } }, [projectId]) // Auto-select the first table whenever the list refreshes and we don't // already have a still-valid selection. useEffect(() => { if (!schema?.tables?.length) { setActiveTbl(null) return } const names = schema.tables.map((t) => t.name) if (!activeTbl || !names.includes(activeTbl)) { setActiveTbl(schema.tables[0].name) setOffset(0) } }, [schema, activeTbl]) // Load rows for the active table whenever the selection or page moves. useEffect(() => { if (!projectId || !activeTbl) { setRows(null); return } let cancelled = false setLoading(true) async function loadRows() { try { const url = `/api/projects/${projectId}/db/rows` + `?table=${encodeURIComponent(activeTbl)}` + `&limit=${PAGE}&offset=${offset}` const r = await fetch(url) if (!r.ok) { const err = await r.json().catch(() => ({})) if (!cancelled) { setRows(null); setError(err.detail || `HTTP ${r.status}`) } return } const data = await r.json() if (cancelled) return setRows(data) setError(null) } catch (e) { if (!cancelled) setError(String(e.message || e)) } finally { if (!cancelled) setLoading(false) } } loadRows() return () => { cancelled = true } }, [projectId, activeTbl, offset]) const tables = schema?.tables ?? [] const activeMeta = useMemo( () => tables.find((t) => t.name === activeTbl), [tables, activeTbl], ) if (!projectId) { return } if (schema && !schema.exists) { return } if (error && !schema) { return } return (
{/* Tables sidebar */}
Tables ({tables.length})
{tables.length === 0 && (
No tables yet. Create one in schema.sql (or via Prisma migrate).
)} {tables.map((t) => { const active = t.name === activeTbl return ( ) })}
{/* Right: schema + rows */}
{activeMeta && (
{activeMeta.name} {activeMeta.columns.length} cols · {activeMeta.row_count} rows
)}
{loading && !rows && (
Loading…
)} {rows && }
) } function RowTable({ rows, columns }) { if (!rows.rows.length) { return (
Empty table — INSERTs from the running app show up here on the next refresh.
) } const colMeta = Object.fromEntries(columns.map((c) => [c.name, c])) return ( {rows.columns.map((c) => ( ))} {rows.rows.map((row, i) => ( {row.map((cell, j) => ( ))} ))}
{c} {colMeta[c]?.pk && ( )}
{colMeta[c]?.type || ''}
{cell == null ? 'NULL' : String(cell)}
) } function Pager({ offset, limit, total, onChange }) { const last = Math.max(0, Math.floor((total - 1) / limit) * limit) const cur = Math.floor(offset / limit) + 1 const max = Math.max(1, Math.floor((total - 1) / limit) + 1) return (
onChange(0)}>« onChange(Math.max(0, offset - limit))}>‹ {cur}/{max} = last} onClick={() => onChange(Math.min(last, offset + limit))}>› = last} onClick={() => onChange(last)}>»
) } function PagerBtn({ disabled, onClick, children }) { return ( ) } function EmptyState({ label, tone }) { return (
{label}
) }