diff --git a/desktop/src/components/db-viewer/SchemaDiffPage.test.tsx b/desktop/src/components/db-viewer/SchemaDiffPage.test.tsx new file mode 100644 index 0000000..601fed8 --- /dev/null +++ b/desktop/src/components/db-viewer/SchemaDiffPage.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +vi.mock("../../lib/commands", () => ({ + compareSchemas: vi.fn(), + getSchemas: vi.fn().mockResolvedValue(["public"]), + getDatabases: vi.fn().mockResolvedValue(["app"]), +})); +vi.mock("../../stores/connectionStore", () => ({ + useConnectionStore: (sel: (s: unknown) => unknown) => + sel({ connections: [{ id: "src1", name: "Source PG", db_type: "postgresql" }] }), +})); + +import { SchemaDiffPage } from "./SchemaDiffPage"; +import { compareSchemas } from "../../lib/commands"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import type { DiffReport } from "../../lib/types"; + +const report: DiffReport = { + source_label: "public · src1", + target_label: "public · tgt", + truncated: false, + items: [ + { + object_type: "table", name: "users", kind: "added", detail: [], + sync_sql: ['CREATE TABLE "users" (id int);'], destructive: false, + }, + { + object_type: "table", name: "old", kind: "removed", detail: [], + sync_sql: ["DROP TABLE old;"], destructive: true, + }, + ], +}; + +describe("SchemaDiffPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + useDbViewerStore.getState().clearChanges(); + (compareSchemas as ReturnType).mockResolvedValue(report); + }); + + it("runs the diff and renders grouped items with kind badges", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /run diff/i })); + await waitFor(() => expect(screen.getByText("users")).toBeTruthy()); + expect(screen.getByText(/added/i)).toBeTruthy(); + expect(screen.getByText(/removed/i)).toBeTruthy(); + expect(screen.getByText(/Destructive/i)).toBeTruthy(); + }); + + it("stages only safe items into the queue", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /run diff/i })); + await waitFor(() => expect(screen.getByText("users")).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /stage 1 safe item/i })); + const queue = useDbViewerStore.getState().changesQueue; + expect(queue).toHaveLength(1); + expect(queue[0].sql).toContain("CREATE TABLE"); + }); + + it("shows the identical empty state", async () => { + (compareSchemas as ReturnType).mockResolvedValue({ + source_label: "a", target_label: "b", truncated: false, items: [], + }); + render(); + fireEvent.click(screen.getByRole("button", { name: /run diff/i })); + await waitFor(() => expect(screen.getByText(/identical/i)).toBeTruthy()); + }); +}); diff --git a/desktop/src/components/db-viewer/SchemaDiffPage.tsx b/desktop/src/components/db-viewer/SchemaDiffPage.tsx new file mode 100644 index 0000000..7002d80 --- /dev/null +++ b/desktop/src/components/db-viewer/SchemaDiffPage.tsx @@ -0,0 +1,275 @@ +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 { 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) => ( + + ))} +
+
+ )) + )} +
+
+ ); +} diff --git a/desktop/src/components/db-viewer/ToolsPage.tsx b/desktop/src/components/db-viewer/ToolsPage.tsx index 055a8ee..62673aa 100644 --- a/desktop/src/components/db-viewer/ToolsPage.tsx +++ b/desktop/src/components/db-viewer/ToolsPage.tsx @@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown"; import { BackupPage } from "./BackupPage"; import { RestorePage } from "./RestorePage"; import { SyncPage } from "./SyncPage"; +import { SchemaDiffPage } from "./SchemaDiffPage"; -type ToolOperation = "backup" | "restore" | "sync"; +type ToolOperation = "backup" | "restore" | "sync" | "diff"; const OPERATION_OPTIONS = [ { value: "backup", label: "Backup" }, { value: "restore", label: "Restore" }, { value: "sync", label: "DB Sync" }, + { value: "diff", label: "Schema Diff" }, ]; export function ToolsPage({ connectionId }: { connectionId: string }) { @@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) { {operation === "backup" && } {operation === "restore" && } {operation === "sync" && } + {operation === "diff" && } );