import { useEffect, useMemo, useRef, useState } from "react"; import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands"; import { controlClass } from "./objects/formRow"; import { useConnectionStore } from "../../stores/connectionStore"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import type { DiffItem, DiffReport } from "../../lib/types"; const TYPE_ORDER = ["enum", "sequence", "table", "column", "constraint", "index", "view"]; const KIND_BADGE: Record = { added: "text-emerald-400 border-emerald-400/40", removed: "text-red-400 border-red-400/40", changed: "text-amber-400 border-amber-400/40", }; /** Threshold above which the diff report is rendered through a virtualizer * (the snapshot cap can reach ~10k cards). Small diffs render plain, which * keeps jsdom tests deterministic and behavior-neutral below the threshold. */ const VIRTUALIZE_THRESHOLD = 200; function familyOf(dbType: string): string { return dbType === "mysql" || dbType === "mariadb" ? "mysql" : dbType; } function SchemaDiffItemCard({ item }: { item: DiffItem }) { return (
{item.kind} {item.name} {item.destructive && ( Destructive — copy only )}
{item.detail.map((d, di) => (
{d.label}: {d.old || "∅"} → {d.new || "∅"}
))} {item.sync_sql.length > 0 && (
SQL
{item.sync_sql.join("\n")}
)}
); } export function SchemaDiffPage({ connectionId }: { connectionId: string }) { const connections = useConnectionStore((s) => s.connections); const currentSchema = useDbViewerStore((s) => s.currentSchema); const currentConnection = connections.find((c) => c.id === connectionId); const family = familyOf(currentConnection?.db_type ?? "postgresql"); const [sourceId, setSourceId] = useState(""); const [sourceSchemas, setSourceSchemas] = useState([]); const [sourceSchema, setSourceSchema] = useState(""); const [targetSchema, setTargetSchema] = useState(currentSchema ?? "public"); const [report, setReport] = useState(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const sameFamilySources = useMemo( () => connections.filter((c) => c.id !== connectionId && familyOf(c.db_type) === family), [connections, connectionId, family], ); const pickSource = async (id: string) => { setSourceId(id); setSourceSchema(""); setSourceSchemas([]); if (!id) return; // Set a synchronous default so "Run diff" works before the async schema // fetch resolves (the source must already be connected). The fetch below // refines it to the source's actual first schema. setSourceSchema(family === "mysql" ? "" : family === "sqlite" ? "main" : "public"); try { // The source must already be connected (opened in the DB viewer) for // capture — getSchemas/getDatabases and compare_schemas error clearly // if not. We do not auto-connect here. const schemas = family === "mysql" ? await getDatabases(id) : family === "sqlite" ? ["main"] : await getSchemas(id); setSourceSchemas(schemas); setSourceSchema(schemas[0] ?? ""); } catch (e) { setError(`Source connection failed: ${e instanceof Error ? e.message : String(e)}`); } }; // Auto-select the first same-family source on mount so "Run diff" works // without manual selection (the source must already be connected). useEffect(() => { if (sourceId === "" && sameFamilySources.length > 0) { void pickSource(sameFamilySources[0].id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sameFamilySources, sourceId]); const runDiff = async () => { if (!sourceId || !sourceSchema || !targetSchema) return; setRunning(true); setError(null); try { const r = await compareSchemas(sourceId, sourceSchema, connectionId, targetSchema); setReport(r); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setRunning(false); } }; const stageAll = () => { if (!report) return; useDbViewerStore.getState().addDiffItems(report.items); }; const copyAll = () => { if (!report) return; const sql = report.items.flatMap((i) => i.sync_sql).join("\n"); void navigator.clipboard?.writeText(sql); }; const grouped = useMemo(() => { if (!report) return [] as [string, DiffItem[]][]; const byType = new Map(); for (const it of report.items) { const list = byType.get(it.object_type) ?? []; list.push(it); byType.set(it.object_type, list); } return [...byType.entries()].sort( (a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]), ); }, [report]); const flattenedItems = report ? report.items : []; const shouldVirtualize = flattenedItems.length > VIRTUALIZE_THRESHOLD; const scrollRef = useRef(null); const virtualizer = useVirtualizer({ count: flattenedItems.length, getScrollElement: () => scrollRef.current, estimateSize: () => 96, overscan: 8, }); const stageableCount = report ? report.items.filter((i) => !i.destructive && i.sync_sql.length > 0).length : 0; return (
Compare a source connection's schema against this connection (same engine family).
→ {report && stageableCount > 0 && ( )} {report && report.items.length > 0 && ( )}
{report?.truncated && (
Snapshot capped at 5,000 objects per side — report is truncated.
)} {error &&
{error}
}
{report && report.items.length === 0 && (
Schemas are identical — no differences found.
)} {shouldVirtualize ? (
{virtualizer.getVirtualItems().map((vi) => (
))}
) : ( grouped.map(([type, items]) => (

{type}s

{items.map((it) => ( ))}
)) )}
); }