Tesseract / studio /src /components /DbBrowserPanel.jsx
rkbosamia's picture
Tesseract major version upgrade - v2
9c49532
Raw
History Blame Contribute Delete
11.9 kB
/**
* 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/<id>/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 <EmptyState label="Open a project to browse its database." />
}
if (schema && !schema.exists) {
return <EmptyState label={`No database yetrun the project to create ${schema.db_path}.`} />
}
if (error && !schema) {
return <EmptyState label={error} tone="error" />
}
return (
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
{/* Tables sidebar */}
<div
style={{
width: 180,
borderRight: '1px solid var(--border)',
background: 'var(--surface)',
overflow: 'auto',
flexShrink: 0,
}}
>
<div
style={{
padding: '8px 10px 6px',
fontSize: 10,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Tables ({tables.length})
</div>
{tables.length === 0 && (
<div style={{ padding: '0 10px', fontSize: 11, color: 'var(--text-faint)' }}>
No tables yet. Create one in schema.sql (or via Prisma migrate).
</div>
)}
{tables.map((t) => {
const active = t.name === activeTbl
return (
<button
key={t.name}
onClick={() => { setActiveTbl(t.name); setOffset(0) }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '6px 10px',
border: 'none',
background: active ? 'var(--surface-2)' : 'transparent',
color: active ? 'var(--text)' : 'var(--text-dim)',
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'ui-monospace, "JetBrains Mono", monospace',
fontSize: 12,
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.name}
</span>
<span style={{ color: 'var(--text-faint)', fontSize: 10 }}>
{t.row_count}
</span>
</button>
)
})}
</div>
{/* Right: schema + rows */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{activeMeta && (
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
background: 'var(--surface)',
display: 'flex',
gap: 12,
alignItems: 'center',
flexWrap: 'wrap',
fontSize: 11,
}}
>
<span
style={{
fontFamily: 'ui-monospace, "JetBrains Mono", monospace',
color: 'var(--text)',
fontWeight: 600,
}}
>
{activeMeta.name}
</span>
<span style={{ color: 'var(--text-faint)' }}>
{activeMeta.columns.length} cols · {activeMeta.row_count} rows
</span>
<span style={{ flex: 1 }} />
<Pager
offset={offset}
limit={PAGE}
total={activeMeta.row_count}
onChange={setOffset}
/>
</div>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{loading && !rows && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--text-faint)' }}>Loading…</div>
)}
{rows && <RowTable rows={rows} columns={activeMeta?.columns ?? []} />}
</div>
</div>
</div>
)
}
function RowTable({ rows, columns }) {
if (!rows.rows.length) {
return (
<div style={{ padding: 16, fontSize: 12, color: 'var(--text-faint)' }}>
Empty table — INSERTs from the running app show up here on the next refresh.
</div>
)
}
const colMeta = Object.fromEntries(columns.map((c) => [c.name, c]))
return (
<table
style={{
borderCollapse: 'separate',
borderSpacing: 0,
width: '100%',
fontSize: 11.5,
fontFamily: 'ui-monospace, "JetBrains Mono", monospace',
color: 'var(--text)',
}}
>
<thead>
<tr>
{rows.columns.map((c) => (
<th
key={c}
style={{
textAlign: 'left',
padding: '6px 10px',
background: 'var(--surface)',
borderBottom: '1px solid var(--border-2)',
fontWeight: 600,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
position: 'sticky',
top: 0,
}}
>
{c}
{colMeta[c]?.pk && (
<span style={{ marginLeft: 5, color: 'var(--accent)', fontSize: 9 }} title="primary key"></span>
)}
<div style={{ fontWeight: 400, fontSize: 9.5, color: 'var(--text-faint)' }}>
{colMeta[c]?.type || ''}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td
key={j}
style={{
padding: '4px 10px',
borderBottom: '1px solid var(--border)',
verticalAlign:'top',
maxWidth: 320,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cell == null ? 'var(--text-faint)' : 'var(--text)',
}}
title={cell == null ? 'NULL' : String(cell)}
>
{cell == null ? 'NULL' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
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 (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', color: 'var(--text-faint)' }}>
<PagerBtn disabled={offset === 0} onClick={() => onChange(0)}>«</PagerBtn>
<PagerBtn disabled={offset === 0} onClick={() => onChange(Math.max(0, offset - limit))}>‹</PagerBtn>
<span style={{ fontSize: 10.5, padding: '0 4px' }}>{cur}/{max}</span>
<PagerBtn disabled={offset >= last} onClick={() => onChange(Math.min(last, offset + limit))}>›</PagerBtn>
<PagerBtn disabled={offset >= last} onClick={() => onChange(last)}>»</PagerBtn>
</div>
)
}
function PagerBtn({ disabled, onClick, children }) {
return (
<button
disabled={disabled}
onClick={onClick}
style={{
width: 22, height: 22,
borderRadius: 4,
border: '1px solid var(--border-2)',
background: 'var(--surface-2)',
color: disabled ? 'var(--text-faint)' : 'var(--text-dim)',
cursor: disabled ? 'default' : 'pointer',
fontSize: 11,
}}
>{children}</button>
)
}
function EmptyState({ label, tone }) {
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: tone === 'error' ? 'var(--danger)' : 'var(--text-faint)',
fontSize: 12.5,
padding: 24,
textAlign: 'center',
background: 'var(--bg)',
}}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" style={{ opacity: 0.4 }}>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5" />
<path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3" />
</svg>
{label}
</div>
)
}