refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- 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
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user