- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Check, X, RotateCcw } from "lucide-react";
|
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
|
import { useUiStore } from "../../stores/uiStore";
|
|
import { useNotificationStore } from "../../stores/notificationStore";
|
|
import * as cmd from "../../lib/commands";
|
|
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
|
import { isSchemaModifyingQuery } from "../../lib/utils";
|
|
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
|
|
|
const statusBg: Record<QueueStatus, string> = {
|
|
pending: "bg-accent/5",
|
|
committed: "bg-green-500/5",
|
|
failed: "bg-red-500/5",
|
|
cancelled: "bg-surface-raised/50",
|
|
};
|
|
|
|
function formatChangeLabel(change: QueueItem): string {
|
|
const schema = change.schema ?? "";
|
|
const table = change.table ?? "";
|
|
const fullName = schema ? `${schema}.${table}` : table;
|
|
switch (change.type) {
|
|
case "bulk_insert":
|
|
return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`;
|
|
case "empty_table":
|
|
return `Empty Table: ${fullName}`;
|
|
case "drop_table":
|
|
return `Drop Table: ${fullName}`;
|
|
case "rebuild_table":
|
|
return change.description ?? `Rebuild ${fullName}`;
|
|
case "ddl":
|
|
return change.description ?? "DDL";
|
|
default:
|
|
return change.table ?? "-";
|
|
}
|
|
}
|
|
|
|
/** Render the old → new value change for update queue items. */
|
|
function formatValueDiff(change: QueueItem): string | null {
|
|
if (change.type !== "update" || !change.newData) return null;
|
|
const colName = Object.keys(change.newData)[0];
|
|
if (!colName) return null;
|
|
const oldVal =
|
|
change.oldData && change.oldData[colName] !== undefined
|
|
? String(change.oldData[colName])
|
|
: "NULL";
|
|
const newVal =
|
|
change.newData[colName] === null || change.newData[colName] === undefined
|
|
? "NULL"
|
|
: String(change.newData[colName]);
|
|
return `${colName}: ${oldVal} → ${newVal}`;
|
|
}
|
|
|
|
function capitalizeType(type: string) {
|
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
}
|
|
|
|
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
|
function badgeLabel(type: string): string {
|
|
if (type === "ddl") return "DDL";
|
|
if (type === "rebuild_table") return "REBUILD";
|
|
return capitalizeType(type);
|
|
}
|
|
|
|
function tableRef(change: QueueItem): string {
|
|
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
|
return change.table ?? "-";
|
|
}
|
|
|
|
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
|
|
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
|
const removeChange = useDbViewerStore((state) => state.removeChange);
|
|
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
|
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
|
|
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
|
|
const notify = useNotificationStore((state) => state.notify);
|
|
|
|
const [view, setView] = useState<"visual" | "sql">("visual");
|
|
|
|
const handleCommitAll = useCallback(async () => {
|
|
const connectionId = useUiStore.getState().activeConnectionId;
|
|
if (!connectionId) {
|
|
notify("No active connection", "error");
|
|
return;
|
|
}
|
|
|
|
const pending = useDbViewerStore.getState().changesQueue.filter(
|
|
(c) => c.status === "pending",
|
|
);
|
|
if (pending.length === 0) return;
|
|
|
|
let committedCount = 0;
|
|
let treeDirty = false;
|
|
|
|
for (const change of pending) {
|
|
try {
|
|
const payload = buildChangePayload(change);
|
|
await cmd.executeChange(connectionId, payload);
|
|
markChangeCommitted(change.id);
|
|
committedCount++;
|
|
if (change.type === "drop_table") {
|
|
treeDirty = true;
|
|
const st = useDbViewerStore.getState();
|
|
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
|
} else if (
|
|
change.type === "rebuild_table" ||
|
|
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
|
|
) {
|
|
treeDirty = true;
|
|
}
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
markChangeFailed(change.id, msg);
|
|
notify(`Change failed: ${msg}`, "error");
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (committedCount > 0) {
|
|
onCommitted?.();
|
|
notify(`${committedCount} change(s) committed`, "success");
|
|
}
|
|
|
|
if (treeDirty) {
|
|
const st = useDbViewerStore.getState();
|
|
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
|
|
}
|
|
}, [markChangeCommitted, markChangeFailed, notify]);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
|
e.preventDefault();
|
|
void handleCommitAll();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
}, [handleCommitAll]);
|
|
|
|
if (changesQueue.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
|
<span className="font-medium text-sm text-text">Pending Changes</span>
|
|
<div className="flex rounded-md border border-border overflow-hidden">
|
|
<button
|
|
type="button"
|
|
aria-label="Visual"
|
|
onClick={() => setView("visual")}
|
|
className={[
|
|
"px-2 py-0.5 text-xs transition-colors",
|
|
view === "visual"
|
|
? "bg-surface-raised text-text"
|
|
: "text-text-muted hover:text-text",
|
|
].join(" ")}
|
|
>
|
|
Visual
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="SQL"
|
|
onClick={() => setView("sql")}
|
|
className={[
|
|
"px-2 py-0.5 text-xs transition-colors",
|
|
view === "sql"
|
|
? "bg-surface-raised text-text"
|
|
: "text-text-muted hover:text-text",
|
|
].join(" ")}
|
|
>
|
|
SQL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto px-2 py-2 space-y-2">
|
|
{view === "visual" ? (
|
|
changesQueue.map((change) => (
|
|
<div
|
|
key={change.id}
|
|
className={`rounded-lg border border-border bg-surface-raised/40 px-3 py-2 ${statusBg[change.status]}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span
|
|
className={[
|
|
"rounded px-1.5 py-0.5 text-xs font-medium",
|
|
change.type === "rebuild_table"
|
|
? "bg-amber-500/10 text-amber-400"
|
|
: "bg-surface-raised text-text-muted",
|
|
].join(" ")}
|
|
>
|
|
{badgeLabel(change.type)}
|
|
</span>
|
|
<span className="text-sm text-text truncate">
|
|
{tableRef(change)}
|
|
</span>
|
|
</div>
|
|
{change.status === "pending" ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Revert change"
|
|
title="Revert change"
|
|
onClick={() => removeChange(change.id)}
|
|
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer shrink-0"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</button>
|
|
) : change.status === "committed" ? (
|
|
<span title="Committed" className="shrink-0 text-green-500">
|
|
<Check className="h-4 w-4" />
|
|
</span>
|
|
) : change.status === "failed" ? (
|
|
<span title="Failed" className="shrink-0 text-red-500">
|
|
<X className="h-4 w-4" />
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{change.type === "rebuild_table" ? (
|
|
<>
|
|
<div className="mt-1 text-xs text-text-muted truncate">
|
|
{formatChangeLabel(change)}
|
|
</div>
|
|
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
|
|
{buildChangeSql(change)}
|
|
</pre>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="mt-1 text-xs text-text-muted truncate">
|
|
{formatChangeLabel(change)}
|
|
</div>
|
|
{formatValueDiff(change) && (
|
|
<div className="mt-0.5 font-mono text-xs text-text">
|
|
<span className="text-text-muted line-through">
|
|
{formatValueDiff(change)!.split(" → ")[0]}
|
|
</span>
|
|
<span className="mx-1 text-text-muted">→</span>
|
|
<span className="text-accent">
|
|
{formatValueDiff(change)!.split(" → ")[1]}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
changesQueue.map((change) => (
|
|
<pre
|
|
key={change.id}
|
|
className="text-xs text-text-muted whitespace-pre-wrap rounded-md bg-canvas px-3 py-2 font-mono border border-border"
|
|
>
|
|
{buildChangeSql(change)}
|
|
</pre>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={clearChanges}
|
|
className="text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-md px-2 py-1 transition-colors cursor-pointer"
|
|
>
|
|
Clear All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={pendingCount === 0}
|
|
onClick={handleCommitAll}
|
|
className="inline-flex items-center rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
>
|
|
Commit All ({pendingCount})
|
|
<kbd className="ml-1.5 rounded bg-surface-raised px-1 text-[10px]">⌘S</kbd>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |