"use client"; import { useEffect, useState, useCallback, useMemo } from "react"; // --- Types --- type ColumnInfo = { name: string; type: string; is_nullable: boolean; }; type TableInfo = { table_name: string; column_count: number; estimated_row_count: number; columns: ColumnInfo[]; }; type SortState = { column: string; direction: "asc" | "desc"; } | null; type DatabaseViewerProps = { /** Relative path to the database file within the workspace */ dbPath: string; filename: string; }; // --- Icons --- function DatabaseIcon({ size = 16 }: { size?: number }) { return ( ); } function TableIcon() { return ( ); } function ViewIcon() { return ( ); } function ColumnIcon() { return ( ); } function PlayIcon() { return ( ); } function ChevronIcon({ direction }: { direction: "left" | "right" }) { return ( {direction === "left" ? ( ) : ( )} ); } function SortIndicator({ active, direction }: { active: boolean; direction: "asc" | "desc" }) { return ( {direction === "asc" ? : } ); } // --- Helpers --- /** Safely convert unknown (DuckDB) value to string for display/sort. */ function safeString(val: unknown): string { if (val == null) {return "";} if (typeof val === "object") {return JSON.stringify(val);} if (typeof val === "string") {return val;} if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);} return ""; } function formatRowCount(n: number): string { if (n >= 1_000_000) {return `${(n / 1_000_000).toFixed(1)}M`;} if (n >= 1_000) {return `${(n / 1_000).toFixed(1)}K`;} return String(n); } /** Map DuckDB type names to short display labels + color hints */ function typeDisplay(dtype: string): { label: string; color: string } { const t = dtype.toUpperCase(); if (t.includes("INT") || t.includes("BIGINT") || t.includes("SMALLINT") || t.includes("TINYINT") || t.includes("HUGEINT")) {return { label: "int", color: "#c084fc" };} if (t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("REAL")) {return { label: "float", color: "#c084fc" };} if (t.includes("BOOL")) {return { label: "bool", color: "#f59e0b" };} if (t.includes("VARCHAR") || t.includes("TEXT") || t.includes("STRING") || t.includes("CHAR") || t === "UUID" || t === "BLOB") {return { label: t.includes("UUID") ? "uuid" : "text", color: "#22c55e" };} if (t.includes("TIMESTAMP") || t.includes("DATETIME")) {return { label: "timestamp", color: "#60a5fa" };} if (t.includes("DATE")) {return { label: "date", color: "#60a5fa" };} if (t.includes("TIME")) {return { label: "time", color: "#60a5fa" };} if (t.includes("JSON")) {return { label: "json", color: "#fb923c" };} return { label: dtype.toLowerCase(), color: "var(--color-text-muted)" }; } // --- DuckDB Not Installed Panel --- /** Shown when the DuckDB CLI binary cannot be found on the system. */ export function DuckDBMissing() { return (

DuckDB is not installed

The DuckDB CLI is required to view database files and workspace data. Click below to install it automatically.

); } // --- Main Component --- export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) { const [tables, setTables] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [duckdbAvailable, setDuckdbAvailable] = useState(true); // Selected table const [selectedTable, setSelectedTable] = useState(null); // Table data const [tableData, setTableData] = useState[]>([]); const [dataLoading, setDataLoading] = useState(false); const [sort, setSort] = useState(null); // Pagination const [page, setPage] = useState(0); const pageSize = 100; // Custom SQL query const [queryMode, setQueryMode] = useState(false); const [sqlInput, setSqlInput] = useState(""); const [queryResult, setQueryResult] = useState[] | null>(null); const [queryError, setQueryError] = useState(null); const [queryRunning, setQueryRunning] = useState(false); // Schema panel toggle const [showSchema, setShowSchema] = useState(false); // Fetch table list on mount useEffect(() => { let cancelled = false; async function introspect() { setLoading(true); setError(null); try { const res = await fetch( `/api/workspace/db/introspect?path=${encodeURIComponent(dbPath)}`, ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `HTTP ${res.status}`); } const data = await res.json(); if (!cancelled) { if (data.duckdb_available === false) { setDuckdbAvailable(false); } else { setTables(data.tables ?? []); // Auto-select first table if (data.tables?.length > 0) { setSelectedTable(data.tables[0].table_name); } } } } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed to introspect database"); } } finally { if (!cancelled) {setLoading(false);} } } void introspect(); return () => { cancelled = true; }; }, [dbPath]); // Fetch table data when selection or page changes const fetchTableData = useCallback( async (tableName: string, offset: number) => { setDataLoading(true); try { const safeName = tableName.replace(/"/g, '""'); const sql = `SELECT * FROM "${safeName}" LIMIT ${pageSize} OFFSET ${offset}`; const res = await fetch("/api/workspace/db/query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: dbPath, sql }), }); if (!res.ok) { setTableData([]); return; } const data = await res.json(); setTableData(data.rows ?? []); } catch { setTableData([]); } finally { setDataLoading(false); } }, [dbPath], ); useEffect(() => { if (selectedTable) { setSort(null); void fetchTableData(selectedTable, page * pageSize); } }, [selectedTable, page, fetchTableData]); // Run custom query const runQuery = useCallback(async () => { if (!sqlInput.trim()) {return;} setQueryRunning(true); setQueryError(null); setQueryResult(null); try { const res = await fetch("/api/workspace/db/query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: dbPath, sql: sqlInput }), }); const data = await res.json(); if (!res.ok) { setQueryError(data.error || `HTTP ${res.status}`); } else { setQueryResult(data.rows ?? []); } } catch (err) { setQueryError(err instanceof Error ? err.message : "Query failed"); } finally { setQueryRunning(false); } }, [dbPath, sqlInput]); // Get selected table info const selectedTableInfo = useMemo( () => tables.find((t) => t.table_name === selectedTable) ?? null, [tables, selectedTable], ); // Sort client-side const sortedData = useMemo(() => { const data = queryMode && queryResult ? queryResult : tableData; if (!sort) {return data;} return [...data].toSorted((a, b) => { const aVal = safeString(a[sort.column]); const bVal = safeString(b[sort.column]); const cmp = aVal.localeCompare(bVal, undefined, { numeric: true }); return sort.direction === "asc" ? cmp : -cmp; }); }, [queryMode, queryResult, tableData, sort]); const handleSort = (column: string) => { setSort((prev) => { if (prev?.column === column) { return prev.direction === "asc" ? { column, direction: "desc" } : null; } return { column, direction: "asc" }; }); }; // Derive columns from data const dataColumns = useMemo(() => { const data = queryMode && queryResult ? queryResult : tableData; if (data.length === 0) {return [];} return Object.keys(data[0]); }, [queryMode, queryResult, tableData]); // Detect database engine from filename const dbEngine = useMemo(() => { const ext = filename.split(".").pop()?.toLowerCase(); if (ext === "duckdb") {return "DuckDB";} if (ext === "sqlite" || ext === "sqlite3") {return "SQLite";} if (ext === "postgres") {return "PostgreSQL";} if (ext === "db") {return "Database";} return "Database"; }, [filename]); // --- Loading state --- if (loading) { return (
Loading database...
); } // --- Error state --- if (error) { return (

Failed to open database

{error}

); } // --- DuckDB not installed --- if (!duckdbAvailable) { return ; } return (
{/* Left panel: Table list */}
{/* Database header */}
{filename}
{dbEngine} · {tables.length} table{tables.length !== 1 ? "s" : ""}
{/* Table list */}
{tables.length === 0 ? (
No tables found
) : ( tables.map((t) => { const isView = t.table_name.startsWith("v_"); const isActive = selectedTable === t.table_name; return ( ); }) )}
{/* Query mode toggle */}
{/* Right panel: Data / Query */}
{queryMode ? ( ) : selectedTableInfo ? ( setShowSchema(!showSchema)} /> ) : (

Select a table to view its data

)}
); } // --- Table Data Panel --- function TableDataPanel({ table, data, dataLoading, dataColumns, sort, onSort, page, pageSize, onPageChange, showSchema, onToggleSchema, }: { table: TableInfo; data: Record[]; dataLoading: boolean; dataColumns: string[]; sort: SortState; onSort: (col: string) => void; page: number; pageSize: number; onPageChange: (page: number) => void; showSchema: boolean; onToggleSchema: () => void; }) { const totalRows = table.estimated_row_count; const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); return (
{/* Table header bar */}
{table.table_name} {/* Stats */}
{table.estimated_row_count.toLocaleString()} rows {table.column_count} columns
{/* Schema panel (collapsible) */} {showSchema && (
{table.columns.map((col) => { const display = typeDisplay(col.type); return (
{col.name} {display.label} {col.is_nullable && ( null )}
); })}
)} {/* Data table */}
{dataLoading ? (
) : data.length === 0 ? (

No data

) : ( )}
{/* Pagination */} {totalRows > pageSize && (
Page {page + 1} of {totalPages}
)}
); } // --- Query Panel --- function QueryPanel({ sqlInput, setSqlInput, queryResult, queryError, queryRunning, runQuery, dataColumns, sortedData, sort, onSort, }: { sqlInput: string; setSqlInput: (v: string) => void; queryResult: Record[] | null; queryError: string | null; queryRunning: boolean; runQuery: () => void; dataColumns: string[]; sortedData: Record[]; sort: SortState; onSort: (col: string) => void; }) { return (
{/* SQL input */}