v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)

* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
2026-08-08 00:09:36 +08:00
committed by GitHub
parent 9fb3222956
commit 32a7b852ec
59 changed files with 4068 additions and 572 deletions
@@ -8,9 +8,15 @@ import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
describe("BackupPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
@@ -25,8 +31,56 @@ describe("BackupPage", () => {
pg_dump_source: "bundled",
pg_restore_source: "bundled",
});
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
});
});
describe("BackupPage DB-aware", () => {
beforeEach(() => {
mockConnections.length = 0;
});
it("shows a single SQL format for MySQL (no custom/tar/directory)", async () => {
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText("Tarball")).toBeNull();
expect(screen.queryByText("Directory")).toBeNull();
expect(screen.getByText("Plain SQL")).toBeTruthy();
});
it("renders a plain file-picker backup for SQLite (no format selector, no tool card)", () => {
mockConnections.push({ id: "c2", db_type: "sqlite", name: "s" });
render(<BackupPage connectionId="c2" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_dump/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
it("still lists Custom Archive for PostgreSQL (regression guard)", async () => {
mockConnections.push({ id: "c3", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
});
render(<BackupPage connectionId="c3" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
});
+365 -127
View File
@@ -5,8 +5,16 @@ import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore";
import {
detectPgTools,
pgDump,
getSchemas,
detectMysqlTools,
mysqlDump,
sqliteDump,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface BackupPageProps {
connectionId: string;
@@ -14,30 +22,51 @@ interface BackupPageProps {
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysqldump is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
return map.darwin;
if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return map.win32;
return map.linux;
}
export function BackupPage({ connectionId }: BackupPageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [singleTransaction, setSingleTransaction] = useState(true);
const [noData, setNoData] = useState(false);
const [routines, setRoutines] = useState(true);
const [triggers, setTriggers] = useState(true);
const [events, setEvents] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -71,74 +100,179 @@ export function BackupPage({ connectionId }: BackupPageProps) {
useEffect(() => {
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => {
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${
let defaultPath = "backup";
let extensions: string[] = [];
if (isPg) {
const pgExtensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
extensions = pgExtensions[format];
defaultPath = `backup.${
format === "custom"
? "dump"
: format === "plain"
? "sql"
: "tar"
}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
}`;
} else if (isMysql) {
extensions = ["sql"];
defaultPath = "backup.sql";
} else {
extensions = ["db", "sqlite", "sql"];
defaultPath = "backup.db";
}
const picked = await save({
defaultPath,
filters: [{ name: "Backup", extensions }],
});
if (picked) setFilePath(picked);
}, [format]);
}, [format, isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
// Command returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore
await action();
} catch (e) {
// If the command itself fails (e.g. connection not found),
// the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
pendingJobRef.current = jobId;
try {
// pgDump returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
} catch (e) {
// If the command itself fails (e.g. connection not found),
// the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
await runWithProgress("dump", () => {
if (isPg) {
return pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
}
if (isMysql) {
return mysqlDump(connectionId, {
database: database!,
filePath,
singleTransaction,
noData,
routines,
triggers,
events,
});
}
return sqliteDump(connectionId, { filePath });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
schema,
noOwner,
singleTransaction,
noData,
routines,
triggers,
events,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_dump_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled"
: false;
const checkingMessage = isPg
? "Checking for pg_dump..."
: isMysql
? "Checking for mysqldump..."
: null;
const headerDescription = isPg
? "Create a database backup via pg_dump"
: isMysql
? "Create a database backup via mysqldump"
: "Create a database backup";
return (
<div className="flex flex-col h-full">
@@ -147,7 +281,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
<HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted">
Create a database backup via pg_dump
{headerDescription}
</span>
</div>
@@ -155,10 +289,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump...
{checkingMessage}
</p>
</div>
)}
@@ -166,14 +300,18 @@ export function BackupPage({ connectionId }: BackupPageProps) {
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_dump not found
{isPg ? "pg_dump not found" : "mysqldump not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre>
</div>
)}
@@ -183,29 +321,40 @@ export function BackupPage({ connectionId }: BackupPageProps) {
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{isPg ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
) : isMysql ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<div className="text-sm text-text py-2">
Plain SQL
</div>
</div>
) : null}
{/* Output file */}
<div className="space-y-1 w-full">
@@ -234,46 +383,136 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* PostgreSQL: no-owner toggle */}
{isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noOwner}
onChange={(e) =>
setNoOwner(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* No-owner toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noOwner}
onChange={(e) =>
setNoOwner(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
{/* MySQL: option toggles */}
{isMysql && (
<div className="space-y-2">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={singleTransaction}
onChange={(e) =>
setSingleTransaction(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--single-transaction
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noData}
onChange={(e) =>
setNoData(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-data
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={routines}
onChange={(e) =>
setRoutines(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--routines
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={triggers}
onChange={(e) =>
setTriggers(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--triggers
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={events}
onChange={(e) =>
setEvents(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--events
</code>
</span>
</label>
</div>
)}
</div>
{/* Progress */}
@@ -304,5 +543,4 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div>
</div>
);
}
}
+17 -1
View File
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands";
import { executeQuery, cancelQuery } from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
@@ -27,6 +27,7 @@ import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import { ToolsPage } from "./ToolsPage";
@@ -184,6 +185,7 @@ export function DbViewerScreen({
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
const viewSupported = capabilities[viewCapabilityKey];
const settings = useSettingsStore((s) => s.settings);
const notify = useNotificationStore((s) => s.notify);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
@@ -230,6 +232,8 @@ export function DbViewerScreen({
const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
const isMatview =
activeTab && activeTab.tabType === "table"
? tables.some(
@@ -1028,6 +1032,18 @@ const onQueriesPanelResizeStart = useCallback(
onRestore={handleRestoreSql}
onRunFromHistory={handleRunFromHistory}
dbType={currentConnection?.db_type}
isRunning={isRunning}
onCancel={async () => {
try {
await cancelQuery(connectionId);
notify("Query cancelled", "info");
} catch (e) {
notify(
e instanceof Error ? e.message : String(e),
"error",
);
}
}}
/>
<div className="flex-1 min-h-0 overflow-hidden">
<QueryEditor
@@ -83,7 +83,7 @@ describe("DbViewerSidebar", () => {
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
});
it("hides Objects and Tools for SQLite", () => {
it("shows Tools but hides Objects for SQLite", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
@@ -92,11 +92,11 @@ describe("DbViewerSidebar", () => {
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
});
it("hides Objects, Visualizer, and Tools for MySQL", () => {
it("shows Tools but hides Objects and Visualizer for MySQL", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
@@ -104,9 +104,9 @@ describe("DbViewerSidebar", () => {
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
});
it("shows no top nav items for Redis (unsupported browsing)", () => {
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { RestorePage } from "./RestorePage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("RestorePage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
});
it("shows format selector for PostgreSQL", async () => {
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
render(<RestorePage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_restore/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
it("renders a plain MySQL restore (no format selector)", async () => {
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c2" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/Plain SQL restores run via psql/i)).toBeNull();
});
it("renders a plain SQLite restore (no format selector, no tool card)", () => {
mockConnections.push({ id: "c3", db_type: "sqlite", name: "s" });
render(<RestorePage connectionId="c3" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
});
+242 -121
View File
@@ -5,38 +5,62 @@ import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore";
import {
detectPgTools,
pgRestore,
getSchemas,
detectMysqlTools,
mysqlRestore,
sqliteRestore,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
return map.darwin;
if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return map.win32;
return map.linux;
}
export function RestorePage({ connectionId }: RestorePageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -68,64 +92,152 @@ export function RestorePage({ connectionId }: RestorePageProps) {
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => {
const extensions = isPg
? ["dump", "sql", "tar", "custom", "gz"]
: isMysql
? ["sql"]
: ["db", "sqlite", "sql"];
const picked = await open({
multiple: false,
filters: [
{
name: "Backup Files",
extensions: ["dump", "sql", "tar", "custom", "gz"],
extensions,
},
],
});
if (picked && typeof picked === "string") setFilePath(picked);
}, []);
}, [isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
pendingJobRef.current = jobId;
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
await runWithProgress("restore", () => {
if (isPg) {
return pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
}
if (isMysql) {
return mysqlRestore(connectionId, {
database: database!,
filePath,
clean,
});
}
return sqliteRestore(connectionId, { filePath, clean });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
clean,
schema,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart = filePath && confirmed && !isRunning;
const checkingMessage = isPg
? "Checking for pg_restore..."
: isMysql
? "Checking for mysql..."
: null;
const headerDescription = isPg
? "Restore a database from a backup file"
: isMysql
? "Restore a database from a SQL dump"
: "Restore a database from a backup file";
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
@@ -133,7 +245,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted">
Restore a database from a backup file
{headerDescription}
</span>
</div>
@@ -141,10 +253,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_restore...
{checkingMessage}
</p>
</div>
)}
@@ -152,14 +264,18 @@ export function RestorePage({ connectionId }: RestorePageProps) {
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_restore not found
{isPg ? "pg_restore not found" : "mysql client not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre>
</div>
)}
@@ -168,28 +284,30 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{/* Format (PostgreSQL only) */}
{isPg && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
)}
{/* Backup file */}
<div className="space-y-1 w-full">
@@ -218,54 +336,58 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Clean toggle */}
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
disabled={format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
</label>
{format === "plain" && (
{(isPg || isMysql || isSqlite) && (
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
isPg && format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
</label>
)}
{isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom
@@ -324,5 +446,4 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
</div>
);
}
}
+1
View File
@@ -58,6 +58,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
targetConnectionId,
schema: schema || undefined,
tables: undefined,
dbType: "postgresql",
});
notify("Sync completed successfully", "success");
onClose();
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { SyncPage } from "./SyncPage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("SyncPage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
});
it("filters target connections to the same db_type as source (mysql)", async () => {
mockConnections.push(
{ id: "pg1", db_type: "postgresql", name: "Postgres 1" },
{ id: "my1", db_type: "mysql", name: "MySQL 1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2" },
{ id: "sq1", db_type: "sqlite", name: "SQLite 1" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<SyncPage />);
await waitFor(() => expect(screen.queryByText(/checking for/i)).not.toBeInTheDocument());
const [sourceSelect, targetSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
const options = Array.from(targetSelect.options).map((o) => o.value);
expect(options).toContain("my1");
expect(options).toContain("my2");
expect(options).not.toContain("pg1");
expect(options).not.toContain("sq1");
});
});
+185 -94
View File
@@ -5,16 +5,24 @@ import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
import {
detectPgTools,
dbSync,
getSchemas,
detectMysqlTools,
mysqlSync,
sqliteSync,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections);
@@ -27,6 +35,13 @@ export function SyncPage() {
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
const sourceConnection = connections.find(
(c) => c.id === sourceConnectionId,
);
const dbType: DbType | null = sourceConnection?.db_type ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
@@ -43,23 +58,44 @@ export function SyncPage() {
}
}, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type
useEffect(() => {
setCheckingTools(true);
setPgToolStatus(null);
setMysqlToolStatus(null);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
}, []);
if (isPg) {
setCheckingTools(true);
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
} else if (isMysql) {
setCheckingTools(true);
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
}
}, [isPg, isMysql]);
// Fetch schemas from the source connection when it changes
useEffect(() => {
@@ -68,10 +104,31 @@ export function SyncPage() {
setSchema("");
return;
}
if (!isPg && !isMysql) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [sourceConnectionId]);
}, [sourceConnectionId, isPg, isMysql]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
@@ -82,35 +139,61 @@ export function SyncPage() {
notify("Source and target must be different", "error");
return;
}
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
pendingJobRef.current = jobId;
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
if (!dbType) {
notify("Unable to determine database type for sync", "error");
return;
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const toolsBundled =
toolStatus?.pg_dump_source === "bundled" &&
toolStatus?.pg_restore_source === "bundled";
const options = {
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
dbType,
};
await runWithProgress("sync", () => {
if (isPg) return dbSync(options);
if (isMysql) return mysqlSync(options);
return sqliteSync(options);
});
}, [
sourceConnectionId,
targetConnectionId,
dbType,
isPg,
isMysql,
schema,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus &&
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
: isMysql
? mysqlToolStatus &&
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled" &&
pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const postgresqlConnections = connections.filter(
(c) => c.db_type === "postgresql",
);
const checkingMessage = isPg
? "Checking for pg_dump / pg_restore..."
: isMysql
? "Checking for mysqldump / mysql..."
: null;
const targetConnections = dbType
? connections.filter((c) => c.db_type === dbType)
: [];
return (
<div className="flex flex-col h-full">
@@ -119,7 +202,7 @@ export function SyncPage() {
<ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted">
Transfer data between PostgreSQL databases via pipe
Transfer data between databases via pipe
</span>
</div>
@@ -127,10 +210,10 @@ export function SyncPage() {
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump / pg_restore...
{checkingMessage}
</p>
</div>
)}
@@ -138,19 +221,27 @@ export function SyncPage() {
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found
{isPg
? "PostgreSQL tools not found"
: "MySQL tools not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Both pg_dump and pg_restore are required for
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
database sync.
</p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{!toolStatus?.pg_dump_found && (
{isPg && !pgToolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li>
)}
{!toolStatus?.pg_restore_found && (
{isPg && !pgToolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
<li>mysqldump is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li>
)}
</ul>
</div>
)}
@@ -168,24 +259,21 @@ export function SyncPage() {
</label>
<select
value={sourceConnectionId}
onChange={(e) =>
onChange={(e) => {
setSourceConnectionId(
e.target.value,
)
}
);
setTargetConnectionId("");
}}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select source...
</option>
{postgresqlConnections.map((c) => (
{connections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
targetConnectionId
}
>
{c.name}
</option>
@@ -204,18 +292,20 @@ export function SyncPage() {
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
Select target...
{sourceConnectionId
? "Select target..."
: "Select a source first"}
</option>
{postgresqlConnections.map((c) => (
{targetConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
sourceConnectionId
c.id === sourceConnectionId
}
>
{c.name}
@@ -226,39 +316,41 @@ export function SyncPage() {
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
))}
</select>
</div>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text">
{postgresqlConnections.find(
{connections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
@@ -268,7 +360,7 @@ export function SyncPage() {
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{postgresqlConnections.find(
{connections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
@@ -328,5 +420,4 @@ export function SyncPage() {
</div>
</div>
);
}
}
@@ -4,6 +4,8 @@ import type { ComponentProps } from "react";
import { TableControls, formatDuration } from "./TableControls";
import { TooltipProvider } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as exportData from "../../lib/exportData";
import type { ViewerTab } from "../../stores/dbViewerStore";
const columns = [
@@ -331,4 +333,28 @@ describe("TableControls", () => {
screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument();
});
it("export dropdown includes the Excel option", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
expect(screen.getByText("CSV")).toBeInTheDocument();
expect(screen.getByText("SQL")).toBeInTheDocument();
expect(screen.getByText("Markdown")).toBeInTheDocument();
expect(screen.getByText("Excel")).toBeInTheDocument();
});
it("notifies after a successful export", () => {
seed([makeTab()], "tab-1");
useNotificationStore.getState().notifications.length = 0;
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
fireEvent.click(screen.getByText("Excel"));
const st = useNotificationStore.getState();
expect(
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
).toBe(true);
});
});
+17 -1
View File
@@ -5,6 +5,7 @@ import {
ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData";
@@ -26,6 +27,7 @@ const EXPORT_FORMATS = [
{ label: "CSV", ext: "csv" },
{ label: "SQL", ext: "sql" },
{ label: "Markdown", ext: "md" },
{ label: "Excel", ext: "xlsx" },
] as const;
// ─── helpers ────────────────────────────────────────────
@@ -430,6 +432,7 @@ export function TableControls({
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
const notify = useNotificationStore((s) => s.notify);
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage);
@@ -495,7 +498,20 @@ export function TableControls({
};
const handleExport = (format: string) => {
exportData(rows, columns, format, table);
const label =
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
try {
exportData(rows, columns, format, table);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
}
setExportOpen(false);
};
@@ -56,6 +56,7 @@ describe("TableOverflowMenu", () => {
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
});
it("fires onOpenTab when menu item clicked", async () => {
@@ -118,6 +119,41 @@ describe("TableOverflowMenu", () => {
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
});
it("Excel export calls exportData and notifies", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(excel\)/i));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "xlsx", "public.t"));
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.toLowerCase().includes("exported"))).toBe(true);
});
it("tree kebab export fetches table data when rows are absent", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const getSpy = vi.spyOn(commands, "getTableData").mockResolvedValue({
columns: [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }],
rows: [[42]],
total_rows: 1,
page: 1,
page_size: 1000,
});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(excel\)/i));
await waitFor(() =>
expect(getSpy).toHaveBeenCalledWith("c1", "public", "t", 1, 1000),
);
await waitFor(() => expect(spy).toHaveBeenCalled());
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.includes("Exported"))).toBe(true);
});
it("maintenance items are gated by capability and run via confirm", async () => {
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
+41 -3
View File
@@ -133,10 +133,47 @@ export function TableOverflowMenu({
case "export-csv":
case "export-json":
case "export-sql":
case "export-md": {
case "export-md":
case "export-xlsx": {
const format = id.replace("export-", "");
if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`);
const label =
format === "xlsx"
? "Excel"
: format === "md"
? "Markdown"
: format.toUpperCase();
try {
if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} else if (connectionId) {
// Tree kebab: no rows are loaded here — fetch the table data
// first, then export (capped at 1000 rows per fetch).
const result = await cmd.getTableData(connectionId, schema, table, 1, 1000);
if (!result.rows.length) {
notify("Nothing to export", "info");
} else {
exportData(result.rows, result.columns, format, `${schema}.${table}`);
const truncated =
result.total_rows > result.rows.length
? ` (first ${result.rows.length} of ${result.total_rows})`
: "";
notify(
`Exported ${result.rows.length} row${result.rows.length === 1 ? "" : "s"} as ${label}${truncated}`,
"success",
);
}
} else {
notify("Nothing to export", "info");
}
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
}
setOpen(false);
break;
@@ -233,6 +270,7 @@ export function TableOverflowMenu({
{ id: "export-json", label: "Export data (JSON)" },
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "export-xlsx", label: "Export data (Excel)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" },
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TableForm } from "./TableForm";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import { useConnectionStore } from "../../../stores/connectionStore";
import * as cmd from "../../../lib/commands";
import * as objectCrud from "../../../lib/objectCrud";
@@ -513,4 +514,118 @@ describe("TableForm", () => {
const local = (await screen.findByLabelText("Local column 1")) as HTMLSelectElement;
expect(local.value).toBe("category_id");
});
});
describe("TableForm SQLite mode", () => {
beforeEach(() => {
useConnectionStore.setState({
connections: [
{
id: "c1",
db_type: "sqlite",
name: "SQLite",
host: "",
port: null,
username: null,
database: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
created_at: "",
updated_at: "",
favorite: false,
} as any,
],
});
});
afterEach(() => {
useConnectionStore.setState({ connections: [] });
});
it("shows SQLite types in the dropdown and not serial", () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "main",
name: "products",
action: {
op: "create",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
const typeSel = screen.getByLabelText("type") as HTMLSelectElement;
const values = Array.from(typeSel.options).map((o) => o.value);
expect(values).toContain("integer");
expect(values).toContain("text");
expect(values).toContain("real");
expect(values).toContain("blob");
expect(values).not.toContain("serial");
});
it("shows INTEGER PRIMARY KEY AUTOINCREMENT in the SQL preview", async () => {
(cmd.buildObjectDdl as any).mockResolvedValue([
'CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)',
]);
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "main",
name: "t",
action: {
op: "create",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getByLabelText("Column settings"));
fireEvent.click(screen.getByLabelText("Auto-Increment"));
fireEvent.click(screen.getByLabelText("SQL"));
expect(
await screen.findByText('CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)'),
).toBeInTheDocument();
});
it("fixes schema to main and hides the schema picker", async () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "t",
action: { op: "create", columns: [] },
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
expect(screen.queryByLabelText("Schema")).toBeNull();
await waitFor(() => {
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("main");
});
});
});
+41 -10
View File
@@ -30,6 +30,7 @@ import type {
ColumnInfo,
ConstraintInfo,
TablespaceInfo,
DbType,
} from "../../../lib/types";
import { getCapabilities } from "../../../lib/dbCapabilities";
import { FkPanel, type FkDefinition } from "./FkPanel";
@@ -75,6 +76,8 @@ const PG_TYPES = [
"money",
];
const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"];
interface TableFormColumn {
rowId: string;
name: string;
@@ -96,6 +99,7 @@ interface SqlColumn {
default: string | null;
is_pk: boolean;
unique?: boolean;
auto_increment?: boolean;
}
interface TableFormAction {
@@ -112,10 +116,14 @@ interface TableFormParams {
action: TableFormAction;
}
function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn {
function toSqlColumn(
c: TableFormColumn,
mode: "create" | "edit",
dbType: DbType | undefined,
): SqlColumn {
let type = c.type;
const base = c.type.trim().toLowerCase();
if (mode === "create" && c.auto_increment) {
if (mode === "create" && c.auto_increment && dbType !== "sqlite") {
if (base === "integer" || base === "int" || base === "int4")
type = "serial";
else if (base === "bigint" || base === "int8") type = "bigserial";
@@ -124,7 +132,7 @@ function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn {
if (c.params && c.params.trim()) {
type = `${type}(${c.params.trim()})`;
}
return {
const out: SqlColumn = {
name: c.name,
type,
nullable: c.nullable,
@@ -132,6 +140,10 @@ function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn {
is_pk: c.is_pk,
unique: c.unique ?? undefined,
};
if (dbType === "sqlite" && c.auto_increment) {
out.auto_increment = true;
}
return out;
}
function sameColumns(a: TableFormColumn[], b: TableFormColumn[]): boolean {
@@ -175,6 +187,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
x: 0,
});
function pickTypeList(dbType: DbType | undefined): string[] {
return dbType === "sqlite" ? SQLITE_TYPES : PG_TYPES;
}
// serial types only exist for the integer family (short + long forms).
function supportsAutoIncrement(type: string): boolean {
const t = type.trim().toLowerCase();
@@ -203,9 +219,12 @@ function buildTablePayload(
params: TableFormParams,
op: "create" | "edit" | "rebuild",
foreignKeys: FkDefinition[] = [],
dbType: DbType | undefined,
): Record<string, unknown> {
const action = params.action;
const sqlColumns = action.columns.map((c) => toSqlColumn(c, action.op));
const sqlColumns = action.columns.map((c) =>
toSqlColumn(c, action.op, dbType),
);
return {
...params,
action: {
@@ -283,6 +302,13 @@ export function TableForm({
);
};
// SQLite has a single schema.
useEffect(() => {
if (dbType === "sqlite" && params.schema !== "main") {
setParams({ ...params, schema: "main" });
}
}, [dbType, params.schema]);
// Rebuild readiness check
useEffect(() => {
if (!isRebuild) {
@@ -343,7 +369,7 @@ export function TableForm({
: cmd.buildObjectDdl(
connectionId,
"table",
buildTablePayload(params, op, createFks),
buildTablePayload(params, op, createFks, dbType),
);
promise
.then((sqls: string[] | string) => {
@@ -469,7 +495,7 @@ export function TableForm({
const sqls = await cmd.buildObjectDdl(
connectionId,
"table",
buildTablePayload(params, op, createFks),
buildTablePayload(params, op, createFks, dbType),
);
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
@@ -581,7 +607,7 @@ export function TableForm({
{view === "visual" ? (
<>
{mode === "create" && (
{mode === "create" && dbType !== "sqlite" && (
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
@@ -697,6 +723,7 @@ export function TableForm({
column: c.name,
})
}
dbType={dbType}
/>
))}
</SortableContext>
@@ -806,6 +833,7 @@ interface ColumnRowProps {
onRemove: () => void;
onFk: () => void;
hasFk: boolean;
dbType: DbType | undefined;
}
function ColumnRow({
@@ -816,6 +844,7 @@ function ColumnRow({
onRemove,
onFk,
hasFk,
dbType,
}: ColumnRowProps) {
const {
attributes,
@@ -872,7 +901,9 @@ function ColumnRow({
Primary key
</label>
{mode === "create" &&
supportsAutoIncrement(c.type) && (
(dbType === "sqlite"
? supportsAutoIncrement(c.type) && c.is_pk
: supportsAutoIncrement(c.type)) && (
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input
type="checkbox"
@@ -982,10 +1013,10 @@ function ColumnRow({
onChange={(e) => setCell(index, "type", e.target.value)}
className="min-w-0 flex-1 bg-transparent font-mono text-xs text-text outline-none cursor-pointer"
>
{c.type !== "" && !PG_TYPES.includes(c.type) && (
{c.type !== "" && !pickTypeList(dbType).includes(c.type) && (
<option value={c.type}>{c.type}</option>
)}
{PG_TYPES.map((t) => (
{pickTypeList(dbType).map((t) => (
<option key={t} value={t}>
{t}
</option>