"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 (
);
}
function SortIndicator({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
return (
);
}
// --- 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 (
);
}
// --- 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 ? (
) : (
)}
{/* 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 */}
{/* Results */}
{queryError && (
)}
{queryResult !== null && queryResult.length === 0 && !queryError && (
Query returned no results
)}
{queryResult !== null && queryResult.length > 0 && (
<>
{queryResult.length} row{queryResult.length !== 1 ? "s" : ""}
>
)}
{queryResult === null && !queryError && (
Write a SQL query and press Run
)}
);
}
// --- Shared Data Table ---
function DataTable({
columns,
rows,
sort,
onSort,
schemaColumns,
}: {
columns: string[];
rows: Record[];
sort: SortState;
onSort: (col: string) => void;
schemaColumns?: ColumnInfo[];
}) {
return (
{/* Row number column */}
|
#
|
{columns.map((col) => {
const schema = schemaColumns?.find((c) => c.name === col);
const display = schema ? typeDisplay(schema.type) : null;
return (
onSort(col)}
>
{col}
{display && (
{display.label}
)}
|
);
})}
{rows.map((row, idx) => (
{
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
}}
>
{/* Row number */}
|
{idx + 1}
|
{columns.map((col) => (
|
))}
))}
);
}
// --- Cell content renderer ---
function CellContent({ value }: { value: unknown }) {
if (value === null || value === undefined) {
return (
null
);
}
if (typeof value === "boolean") {
return (
{value ? "true" : "false"}
);
}
if (typeof value === "number") {
return {value};
}
const str = safeString(value);
// Truncate very long values
if (str.length > 120) {
return (
{str.slice(0, 120)}
...
);
}
return {str};
}