Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion database/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"watch": "node build.mjs --watch"
},
"dependencies": {
"@iii-dev/console-ui": "workspace:*"
"@iii-dev/console-ui": "workspace:*",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
Expand Down
19 changes: 14 additions & 5 deletions database/ui/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,27 @@
* asset: ../styles.css ships over `console:style` as database/styles.css —
* the console mounts and link-swaps it, styles-before-scripts on boot.
*
* `setup(host)` registers one contribution: the function-trigger renderer
* in src/function-trigger-message/ — how every database::* call renders in
* chat and traces (SQL, request chips, result tables). No page and no
* config form yet; those slots can be added here later.
* `setup(host)` registers two contributions:
* - src/function-trigger-message/ — how every database::* call renders in
* chat and traces (SQL, request chips, result tables).
* - src/page/ — the `#/ext/database` browser: schema tree, sortable row
* grid, row inspector, and a read-only SQL editor (shared Monaco). Reads
* the live database over `database::query`/`database::listDatabases`.
*
* Registration goes through `host` so the loader disposes it on hot
* Registrations go through `host` so the loader disposes them on hot
* reload / worker disconnect.
*/

import type { Host } from '@iii-dev/console-ui'
import { createDatabaseTriggerRenderer } from './src/function-trigger-message'
import { DatabasePage } from './src/page'

export default function setup(host: Host) {
host.functionTriggers.register(createDatabaseTriggerRenderer(host))

host.pages.register({
id: 'database',
title: 'database',
render: () => <DatabasePage host={host} />,
})
}
92 changes: 92 additions & 0 deletions database/ui/src/page/RowDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Inspector for one selected row: every column with its full (untruncated)
* value, JSON pretty-printed, one copy button per field. Complements the
* grid, which truncates long values to keep rows scannable.
*/

import { Button, JsonHighlight } from '@iii-dev/console-ui'
import { useState } from 'react'
import { cellText, copyText } from './cells'
import type { ColumnInfo } from './db-data'
import { Check, Copy, KeyRound, X } from './icons'

interface RowDetailProps {
table: string
row: Record<string, unknown>
columns: ColumnInfo[]
onClose: () => void
}

export function RowDetail({ table, row, columns, onClose }: RowDetailProps) {
const [copied, setCopied] = useState<string | null>(null)
const names =
columns.length > 0 ? columns.map((c) => c.name) : Object.keys(row)
const byName = new Map(columns.map((c) => [c.name, c]))

const copyValue = (name: string) => {
copyText(cellText(row[name]))
setCopied(name)
window.setTimeout(() => {
setCopied((cur) => (cur === name ? null : cur))
}, 1200)
}

return (
<aside className="db-rowdetail">
<div className="db-rowdetail-head">
<span className="cap">row · {table}</span>
<Button
variant="icon"
size="icon"
onClick={onClose}
aria-label="close row detail"
>
<X size={14} />
</Button>
</div>
{names.map((name) => {
const col = byName.get(name)
const value = row[name]
const isJson =
value !== null && value !== undefined && typeof value === 'object'
return (
<div key={name} className="db-field">
<div className="db-field-head">
{col?.pk ? (
<KeyRound size={10} style={{ color: 'var(--color-accent)' }} />
) : null}
<span className="db-field-name">{name}</span>
{col?.type ? (
<span className="db-field-type">{col.type}</span>
) : null}
<button
type="button"
className={`db-field-copy${copied === name ? ' copied' : ''}`}
onClick={() => copyValue(name)}
aria-label={`copy ${name}`}
>
{copied === name ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
<div className="db-field-value">
{value === null || value === undefined ? (
<span className="null">NULL</span>
) : isJson ? (
<JsonHighlight code={JSON.stringify(value, null, 2)} />
) : typeof value === 'boolean' ? (
<span className={value ? 'bool-true' : 'bool-false'}>
{String(value)}
</span>
) : (
<span className="plain">{String(value)}</span>
)}
</div>
{col?.fkTarget ? (
<div className="db-field-fk">references {col.fkTarget}</div>
) : null}
</div>
)
})}
</aside>
)
}
225 changes: 225 additions & 0 deletions database/ui/src/page/SchemaTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Sidebar schema explorer, grouped into tables and views. Each entry expands
* to its column list (name, type, PK/FK markers) plus indexes for tables,
* fetched lazily per table (through the tab's `host`) and cached for the life
* of the component — the parent remounts it on db switch / refresh, which is
* the cache-invalidation point.
*/

import { type Host, Skeleton } from '@iii-dev/console-ui'
import { type ComponentType, useState } from 'react'
import {
type ColumnInfo,
type DbDriver,
type DbTable,
type IndexInfo,
tableColumns,
tableIndexes,
} from './db-data'
import {
ChevronRight,
Eye,
type IconProps,
KeyRound,
Link2,
Table2,
} from './icons'

interface SchemaTreeProps {
host: Host
db: string
driver: DbDriver
tables: DbTable[]
selectedTable: string | null
onSelectTable: (name: string) => void
}

interface TableSchema {
columns: ColumnInfo[]
indexes: IndexInfo[]
}

export function SchemaTree({
host,
db,
driver,
tables,
selectedTable,
onSelectTable,
}: SchemaTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [schemaByTable, setSchemaByTable] = useState<
Record<string, TableSchema | 'loading' | 'error'>
>({})

const toggle = (table: DbTable) => {
setExpanded((cur) => {
const next = new Set(cur)
if (next.has(table.name)) {
next.delete(table.name)
} else {
next.add(table.name)
}
return next
})
if (schemaByTable[table.name] === undefined) {
setSchemaByTable((cur) => ({ ...cur, [table.name]: 'loading' }))
void Promise.all([
tableColumns(host, db, driver, table.name),
table.kind === 'table'
? tableIndexes(host, db, driver, table.name).catch(() => [])
: Promise.resolve([]),
])
.then(([columns, indexes]) => {
setSchemaByTable((cur) => ({
...cur,
[table.name]: { columns, indexes },
}))
})
.catch(() => {
setSchemaByTable((cur) => ({ ...cur, [table.name]: 'error' }))
})
}
}

const groups: {
label: string
kind: DbTable['kind']
icon: ComponentType<IconProps>
}[] = [
{ label: 'tables', kind: 'table', icon: Table2 },
{ label: 'views', kind: 'view', icon: Eye },
]

return (
<div className="db-tree">
{groups.map((group) => {
const entries = tables.filter((t) => t.kind === group.kind)
if (entries.length === 0) return null
return (
<div key={group.kind}>
{group.kind === 'view' ? (
<div className="db-tree-grouphead">views · {entries.length}</div>
) : null}
<ul>
{entries.map((table) => {
const isOpen = expanded.has(table.name)
const isSelected = selectedTable === table.name
const schema = schemaByTable[table.name]
const Icon = group.icon
return (
<li key={table.name}>
<div
className={`db-tree-row${isSelected ? ' active' : ''}`}
>
<button
type="button"
className="db-tree-toggle"
onClick={() => toggle(table)}
aria-label={
isOpen
? `collapse ${table.name} columns`
: `expand ${table.name} columns`
}
>
<ChevronRight
size={12}
style={{
transform: isOpen ? 'rotate(90deg)' : undefined,
transition: 'transform 0.12s',
}}
/>
</button>
<button
type="button"
className="db-tree-name"
onClick={() => onSelectTable(table.name)}
title={table.name}
>
<Icon
size={12}
style={{ color: 'var(--color-ink-ghost)' }}
/>
<span className="db-trunc">{table.name}</span>
</button>
</div>
{isOpen ? <TableSchemaRows schema={schema} /> : null}
</li>
)
})}
</ul>
</div>
)
})}
</div>
)
}

function TableSchemaRows({
schema,
}: {
schema: TableSchema | 'loading' | 'error' | undefined
}) {
if (schema === 'loading' || schema === undefined) {
return (
<div style={{ padding: '4px 12px 4px 36px' }}>
<Skeleton style={{ display: 'block', height: 14, width: 96 }} />
</div>
)
}
if (schema === 'error') {
return <p className="db-tree-msg alert">failed to read schema</p>
}
return (
<ul className="db-cols">
{schema.columns.length === 0 ? (
<li className="db-tree-msg">no columns</li>
) : (
schema.columns.map((col) => (
<li
key={col.name}
className="db-col"
title={[
col.type,
col.nullable ? 'nullable' : 'not null',
col.pk ? 'primary key' : null,
col.fkTarget ? `references ${col.fkTarget}` : null,
]
.filter(Boolean)
.join(' · ')}
>
{col.pk ? (
<KeyRound size={10} style={{ color: 'var(--color-accent)' }} />
) : col.fkTarget ? (
<Link2 size={10} style={{ color: 'var(--color-ink-ghost)' }} />
) : (
<span style={{ width: 10, flexShrink: 0 }} />
)}
<span className="db-col-name">{col.name}</span>
<span className="db-col-type">{col.type}</span>
</li>
))
)}
{schema.indexes.length > 0 ? (
<>
<li className="db-idx-head">indexes</li>
{schema.indexes.map((idx) => (
<li
key={idx.name}
className="db-idx"
title={[idx.unique ? 'unique' : null, idx.detail]
.filter(Boolean)
.join(' · ')}
>
<span style={{ width: 10, flexShrink: 0 }} />
<span className="db-idx-name">{idx.name}</span>
{idx.unique ? (
<span className="db-idx-unique">unique</span>
) : null}
</li>
))}
</>
) : null}
</ul>
)
}
Loading
Loading