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:
+3
-1
@@ -10,6 +10,7 @@ import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { isMacOS } from "./lib/platform";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
const VIEW_TITLES: Record<string, string> = {
|
||||
@@ -79,7 +80,8 @@ export default function App() {
|
||||
return (
|
||||
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
|
||||
{typeof window !== "undefined" &&
|
||||
"__TAURI_INTERNALS__" in window && (
|
||||
"__TAURI_INTERNALS__" in window &&
|
||||
isMacOS() && (
|
||||
// macOS "Overlay" title bar: in-flow strip the window can be
|
||||
// dragged by; traffic lights float over it. Only in Tauri.
|
||||
<div
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" />);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -78,6 +78,32 @@ describe("QueryToolbar", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
onRun: () => {},
|
||||
onFormat: () => {},
|
||||
connectionId: "conn-1",
|
||||
onRestore: () => {},
|
||||
onRunFromHistory: () => {},
|
||||
};
|
||||
|
||||
it("shows a Cancel button when isRunning is true", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<QueryToolbar {...baseProps} isRunning={true} onCancel={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Cancel button when not running", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<QueryToolbar {...baseProps} isRunning={false} onCancel={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Run Query and the format icon button", () => {
|
||||
renderToolbar({});
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Play, Wand2, Save } from "lucide-react";
|
||||
import { Play, Square, Wand2, Save } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
|
||||
import { SaveQueryDialog } from "./SaveQueryDialog";
|
||||
@@ -36,6 +36,8 @@ interface QueryToolbarProps {
|
||||
onRunFromHistory: (sql: string) => void;
|
||||
dbType?: DbType;
|
||||
readOnly?: boolean;
|
||||
isRunning?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function QueryToolbar({
|
||||
@@ -46,10 +48,13 @@ export function QueryToolbar({
|
||||
onRunFromHistory,
|
||||
dbType,
|
||||
readOnly = false,
|
||||
isRunning: isRunningProp,
|
||||
onCancel,
|
||||
}: QueryToolbarProps) {
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false;
|
||||
const isRunning =
|
||||
isRunningProp ?? tabs.find((t) => t.id === activeTabId)?.loading ?? false;
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
@@ -100,6 +105,21 @@ export function QueryToolbar({
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Cancel Query */}
|
||||
{isRunning && onCancel && (
|
||||
<Tooltip content="Cancel running query" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1.5 rounded-md border border-red-500/50 bg-red-500/10 px-2.5 py-1 font-medium text-red-400 transition-colors hover:bg-red-500/20 cursor-pointer"
|
||||
aria-label="Cancel query"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* History dropdown */}
|
||||
<QueryHistoryDropdown
|
||||
connectionId={connectionId}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
theme: "system",
|
||||
font_size: "medium",
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
exportSettings: vi.fn().mockResolvedValue('{"schemaVersion":1,"settings":{}}'),
|
||||
importSettings: vi.fn().mockResolvedValue(undefined),
|
||||
recreateDemoDb: vi.fn().mockResolvedValue("Demo re-added"),
|
||||
regenerateDemoDb: vi.fn().mockResolvedValue("Demo regenerated"),
|
||||
}));
|
||||
|
||||
const baseSettings = {
|
||||
theme: "system" as const,
|
||||
font_size: "medium" as const,
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off" as const,
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
};
|
||||
|
||||
describe("GeneralSettingsTab settings export/import", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ settings: baseSettings, loading: false, error: null });
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
});
|
||||
|
||||
it("renders Export Settings and Import Settings buttons", () => {
|
||||
render(<GeneralSettingsTab />);
|
||||
expect(screen.getByRole("button", { name: /export settings/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /import settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports settings to the chosen file and shows a success toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const exportedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
|
||||
(commands.exportSettings as ReturnType<typeof vi.fn>).mockResolvedValue(exportedJson);
|
||||
(save as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
|
||||
(writeTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /export settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextFile).toHaveBeenCalledWith("/tmp/gridline-settings.json", exportedJson);
|
||||
});
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({ type: "success", message: expect.stringMatching(/exported/i) })
|
||||
);
|
||||
});
|
||||
|
||||
it("imports settings from the chosen file, reloads settings, and shows a success toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const importedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(importedJson);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(commands.importSettings).toHaveBeenCalledWith(importedJson);
|
||||
});
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({ type: "success", message: expect.stringMatching(/imported/i) })
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces an error toast when importing an invalid settings file", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const invalidJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
settings: { ...baseSettings, theme: "purple" },
|
||||
});
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(invalidJson);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringMatching(/invalid settings/i),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an error toast when importing malformed JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringMatching(/invalid settings/i),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ import { AccentPicker } from "../ui/AccentPicker";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { validateSettingsExport } from "../../lib/settingsImport";
|
||||
import type { FontSize } from "../../lib/types";
|
||||
import { useState } from "react";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
|
||||
{ value: "small", label: "Small" },
|
||||
@@ -72,6 +74,53 @@ export function GeneralSettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const json = await cmd.exportSettings();
|
||||
const path = await save({
|
||||
defaultPath: "gridline-settings.json",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
await writeTextFile(path, json);
|
||||
notify("Settings exported", "success");
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const p = await open({
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!p || Array.isArray(p)) return;
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const text = await readTextFile(p);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
notify("Invalid settings: file is not valid JSON", "error");
|
||||
return;
|
||||
}
|
||||
const r = validateSettingsExport(parsed);
|
||||
if (!r.ok) {
|
||||
notify(
|
||||
`Invalid settings: ${r.errors.map((e) => e.field).join(", ")}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await cmd.importSettings(text);
|
||||
await load();
|
||||
notify("Settings imported", "success");
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
@@ -148,6 +197,36 @@ export function GeneralSettingsTab() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Data</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
title="Export settings"
|
||||
description="Save your settings to a JSON file."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Export settings
|
||||
</button>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Import settings"
|
||||
description="Restore settings from a previously exported JSON file."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Import settings
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Demo</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.7)", () => {
|
||||
describe("tauri bundle config (v0.7.8)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.7", () => {
|
||||
expect(tauriConf.version).toBe("0.7.7");
|
||||
it("version is 0.7.8", () => {
|
||||
expect(tauriConf.version).toBe("0.7.8");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import * as cmd from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
|
||||
@@ -423,4 +424,12 @@ describe("v0.7.7 command wrappers", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
|
||||
expect(result).toEqual(mockScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.8 command wrappers exist", () => {
|
||||
it("exports the backup/cancel/settings wrappers", () => {
|
||||
for (const name of ["cancelQuery","mysqlDump","mysqlRestore","mysqlSync","detectMysqlTools","sqliteDump","sqliteRestore","sqliteSync","exportSettings","importSettings"]) {
|
||||
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
+43
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
@@ -162,6 +162,48 @@ export async function dbSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("db_sync", { options });
|
||||
}
|
||||
|
||||
// ─── v0.7.8: Cancel / MySQL / SQLite / Settings export-import ────
|
||||
|
||||
export async function cancelQuery(connectionId: string): Promise<void> {
|
||||
return invoke<void>("cancel_query", { connectionId });
|
||||
}
|
||||
|
||||
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools");
|
||||
}
|
||||
|
||||
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
|
||||
return invoke<string>("mysql_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlRestore(connectionId: string, options: MySqlRestoreOptions): Promise<string> {
|
||||
return invoke<string>("mysql_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("mysql_sync", { options });
|
||||
}
|
||||
|
||||
export async function sqliteDump(connectionId: string, options: SqliteBackupOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteRestore(connectionId: string, options: SqliteRestoreOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_sync", { options });
|
||||
}
|
||||
|
||||
export async function exportSettings(): Promise<string> {
|
||||
return invoke<string>("export_settings");
|
||||
}
|
||||
|
||||
export async function importSettings(json: string): Promise<void> {
|
||||
return invoke<void>("import_settings", { json });
|
||||
}
|
||||
|
||||
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
|
||||
|
||||
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
|
||||
|
||||
@@ -11,19 +11,19 @@ describe("dbCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => {
|
||||
it("gives MySQL explorer/queries/editing/import/ddl/tools but not objects/visualizer", () => {
|
||||
const c = DB_CAPABILITIES.mysql;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.visualizer).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => {
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
@@ -31,8 +31,9 @@ describe("dbCapabilities", () => {
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.tableManagement).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
@@ -79,14 +80,37 @@ describe("v0.7.7 capabilities", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
|
||||
});
|
||||
it("mysql/sqlite/redis disable maintenance, roles, tableManagement", () => {
|
||||
it("mysql/sqlite/redis disable maintenance and roles; only sqlite also gets tableManagement", () => {
|
||||
for (const t of ["mysql", "sqlite", "redis"] as const) {
|
||||
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].roles).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].tableManagement).toBe(false);
|
||||
}
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.tableManagement).toBe(false);
|
||||
});
|
||||
it("getCapabilities is safe for unknown types", () => {
|
||||
expect(getCapabilities("bogus").maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dbCapabilities v0.7.8", () => {
|
||||
it("enables tools for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tools).toBe(true);
|
||||
});
|
||||
|
||||
it("enables tableManagement for sqlite", () => {
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports editing/import/ddl for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.import).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.ddl).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.objects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,8 @@ const ALL_FALSE: DbCapabilities = {
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.7 docs coverage", () => {
|
||||
describe("v0.7.8 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.7 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.7", () => {
|
||||
expect(readme).toContain("0.7.7");
|
||||
it("README declares v0.7.8", () => {
|
||||
expect(readme).toContain("0.7.8");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -59,9 +59,14 @@ describe("v0.7.7 docs coverage", () => {
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.7 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.7/");
|
||||
expect(readme).toContain("Gridline_0.7.7_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm");
|
||||
it("AGENTS.md marks xlsx/SQLite-dump/cancel/settings-import complete", () => {
|
||||
expect(agents).toMatch(/Excel \(\.xlsx\) export \| ✅/);
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.8 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.8/");
|
||||
expect(readme).toContain("Gridline_0.7.8_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.8-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,26 @@ describe("exportData", () => {
|
||||
exportData([[1, "a"]], columns, "json", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("xlsx produces a Blob with the xlsx MIME and extension download", () => {
|
||||
const origCreateObjectURL = URL.createObjectURL;
|
||||
const origRevokeObjectURL = URL.revokeObjectURL;
|
||||
globalThis.URL.createObjectURL = vi.fn(() => "blob:x") as any;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as any;
|
||||
const a = { click: vi.fn(), href: "", download: "" };
|
||||
vi.spyOn(document, "createElement").mockReturnValue(a as any);
|
||||
const rows = [[1]];
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
try {
|
||||
exportData(rows, cols, "xlsx", "t");
|
||||
expect(a.download).toBe("t.xlsx");
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0] instanceof Blob).toBe(true);
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0].type).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
} finally {
|
||||
globalThis.URL.createObjectURL = origCreateObjectURL;
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
export function exportData(
|
||||
@@ -11,6 +12,17 @@ export function exportData(
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "xlsx": {
|
||||
const bytes = buildXlsx(rows, columns);
|
||||
const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { isMacOS } from "./platform";
|
||||
|
||||
describe("isMacOS", () => {
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
});
|
||||
|
||||
it("true on MacIntel/Mac platform", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; X)" });
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
|
||||
it("false on Win32", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32", userAgent: "Mozilla/5.0 (Windows NT 10.0)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
|
||||
it("false on Linux", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; Linux)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** True only on macOS (the macOS "Overlay" drag strip is gated on this). Uses
|
||||
* navigator.platform/userAgent (the established pattern in BackupPage.tsx);
|
||||
* @tauri-apps/plugin-os is not installed and getCurrentWindow() has no osLabel(). */
|
||||
export function isMacOS(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const p = (navigator.platform || "").toLowerCase();
|
||||
const u = (navigator.userAgent || "").toLowerCase();
|
||||
return p.includes("mac") || u.includes("macintosh") || u.includes("mac os");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateSettingsExport } from "./settingsImport";
|
||||
import type { Settings } from "./types";
|
||||
|
||||
const good: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||
default_ports: { postgresql: 5432 }, tag_order: null, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: { open_command_palette: "Cmd+K" }, accent_color: "#2563EB",
|
||||
editor_font_size: 14, editor_font_family: "Menlo", editor_word_wrap: "off", editor_minimap: true, editor_tab_size: 2,
|
||||
};
|
||||
|
||||
describe("validateSettingsExport", () => {
|
||||
it("accepts a well-formed object", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: good });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid theme", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, theme: "purple" as never } });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.errors.some((e) => e.field === "theme")).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps editor_font_size to [8,24]", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, editor_font_size: 999 } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.settings.editor_font_size).toBe(24);
|
||||
});
|
||||
|
||||
it("tolerates unknown keys (version skew)", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 99, settings: { ...good, futureField: true } } as never);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a non-negative table_refresh_rate", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, table_refresh_rate: -1 } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Settings } from "./types";
|
||||
|
||||
export interface SettingsExport { schemaVersion: number; settings: Settings }
|
||||
export type ValidationOk = { ok: true; settings: Settings };
|
||||
export type ValidationErr = { ok: false; errors: { field: string; message: string }[] };
|
||||
export type ValidationResult = ValidationOk | ValidationErr;
|
||||
|
||||
const THEMES = ["dark", "light", "system"];
|
||||
const FONT_SIZES = ["small", "medium", "large"];
|
||||
const FONT_FAMILIES = ["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||
const WORD_WRAPS = ["off", "on"];
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
||||
|
||||
export function validateSettingsExport(input: unknown): ValidationResult {
|
||||
const errors: ValidationErr["errors"] = [];
|
||||
if (typeof input !== "object" || input === null || !("settings" in input)) {
|
||||
return { ok: false, errors: [{ field: "settings", message: "missing settings object" }] };
|
||||
}
|
||||
const s = (input as { settings: Record<string, unknown> }).settings;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enumCheck = (field: keyof Settings, allow: readonly string[], val: unknown) => {
|
||||
if (typeof val === "string" && allow.includes(val)) out[field] = val;
|
||||
else errors.push({ field, message: `invalid ${field}` });
|
||||
};
|
||||
enumCheck("theme", THEMES, s.theme);
|
||||
enumCheck("font_size", FONT_SIZES, s.font_size);
|
||||
enumCheck("editor_word_wrap", WORD_WRAPS, s.editor_word_wrap);
|
||||
|
||||
if (typeof s.accent_color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s.accent_color)) out.accent_color = s.accent_color;
|
||||
else errors.push({ field: "accent_color", message: "invalid hex color" });
|
||||
|
||||
if (typeof s.editor_font_size === "number") out.editor_font_size = clamp(Math.trunc(s.editor_font_size), 8, 24);
|
||||
else errors.push({ field: "editor_font_size", message: "must be a number" });
|
||||
if (typeof s.editor_tab_size === "number") out.editor_tab_size = clamp(Math.trunc(s.editor_tab_size), 2, 8);
|
||||
else errors.push({ field: "editor_tab_size", message: "must be a number" });
|
||||
enumCheck("editor_font_family", FONT_FAMILIES, s.editor_font_family);
|
||||
if (typeof s.editor_minimap === "boolean") out.editor_minimap = s.editor_minimap;
|
||||
else errors.push({ field: "editor_minimap", message: "must be boolean" });
|
||||
|
||||
if (typeof s.table_page_size === "number" && s.table_page_size > 0) out.table_page_size = Math.trunc(s.table_page_size);
|
||||
else errors.push({ field: "table_page_size", message: "must be positive" });
|
||||
if (typeof s.table_refresh_rate === "number" && s.table_refresh_rate >= 0) out.table_refresh_rate = s.table_refresh_rate;
|
||||
else errors.push({ field: "table_refresh_rate", message: "must be non-negative" });
|
||||
|
||||
out.confirm_before_delete = typeof s.confirm_before_delete === "boolean" ? s.confirm_before_delete : true;
|
||||
out.default_folder_id = typeof s.default_folder_id === "string" || s.default_folder_id === null ? s.default_folder_id : null;
|
||||
out.tag_order = typeof s.tag_order === "string" || s.tag_order === null ? s.tag_order : null;
|
||||
out.default_ports = s.default_ports && typeof s.default_ports === "object" ? s.default_ports : {};
|
||||
out.shortcuts = s.shortcuts && typeof s.shortcuts === "object" ? s.shortcuts : {};
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return { ok: true, settings: out as unknown as Settings };
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export interface SyncOptions {
|
||||
targetConnectionId: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
dbType: DbType;
|
||||
}
|
||||
|
||||
export interface PgToolStatus {
|
||||
@@ -313,6 +314,49 @@ export interface PgToolStatus {
|
||||
pg_restore_source: string | null;
|
||||
}
|
||||
|
||||
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
|
||||
// NOTE: the Rust `MySqlToolStatus` model is `#[serde(rename_all = "camelCase")]`,
|
||||
// so these interfaces use camelCase keys to match the actual IPC payloads.
|
||||
|
||||
export interface MySqlToolStatus {
|
||||
mysqldumpFound: boolean;
|
||||
mysqlFound: boolean;
|
||||
mysqldumpVersion: string | null;
|
||||
mysqlVersion: string | null;
|
||||
mysqldumpSource: string | null;
|
||||
mysqlSource: string | null;
|
||||
}
|
||||
|
||||
export interface MySqlBackupOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
singleTransaction: boolean;
|
||||
noData: boolean;
|
||||
routines: boolean;
|
||||
triggers: boolean;
|
||||
events: boolean;
|
||||
}
|
||||
|
||||
export interface MySqlRestoreOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteBackupOptions {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface SqliteRestoreOptions {
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsExport {
|
||||
schemaVersion: number;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type PgObjectType =
|
||||
| "table" | "view" | "materialized view" | "function" | "procedure"
|
||||
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.7 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.7");
|
||||
it("declares v0.7.8 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unzipSync } from "fflate";
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ 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 },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
function sheetXML(out: Uint8Array): string {
|
||||
const files = unzipSync(out);
|
||||
return new TextDecoder().decode(files["xl/worksheets/sheet1.xml"]);
|
||||
}
|
||||
|
||||
describe("buildXlsx", () => {
|
||||
it("emits headers + rows as inline strings", () => {
|
||||
const out = buildXlsx([[1, "Alice"], [2, "Bob"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain('t="inlineStr"');
|
||||
expect(xml).toContain("id");
|
||||
expect(xml).toContain("Alice");
|
||||
expect(xml).toContain("Bob");
|
||||
});
|
||||
|
||||
it("escapes XML-special characters in cell text", () => {
|
||||
const out = buildXlsx([[1, "a<b>&c"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("a<b>&c");
|
||||
expect(xml).not.toContain("a<b>&c");
|
||||
});
|
||||
|
||||
it("emits formula-triggering values as inline strings (no formula evaluation)", () => {
|
||||
const out = buildXlsx([[1, "=1+1"], [2, "+5"], [3, "@SUM"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("=1+1");
|
||||
expect(xml).not.toMatch(/<c[^>]*?><f>/);
|
||||
});
|
||||
|
||||
it("declares a column width per header", () => {
|
||||
const out = buildXlsx([[1, "x"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("<cols>");
|
||||
expect(xml).toContain("width=");
|
||||
});
|
||||
|
||||
it("renders null cells as empty inline strings", () => {
|
||||
const out = buildXlsx([[1, null]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("inlineStr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Minimal OOXML spreadsheet: one sheet, every cell as an inline string
|
||||
* (t="inlineStr") so Excel never evaluates a cell as a formula — the
|
||||
* formula-injection mitigation required by the spec. */
|
||||
const XML_ESCAPES: [RegExp, string][] = [
|
||||
[/&/g, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
];
|
||||
function esc(s: string): string {
|
||||
for (const [re, w] of XML_ESCAPES) s = s.replace(re, w);
|
||||
return s;
|
||||
}
|
||||
function colLetter(n: number): string {
|
||||
let s = "";
|
||||
for (let i = n; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildColsXML(columns: ColumnInfo[]): string {
|
||||
const maxW = columns.map((c) => Math.min(60, Math.max(8, c.name.length + 2)));
|
||||
return `<cols>${maxW.map((w, i) => `\n <col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}\n</cols>`;
|
||||
}
|
||||
|
||||
function buildCellsXML(rows: unknown[][], columns: ColumnInfo[]): string {
|
||||
let cells = "";
|
||||
cells += `<row r="1">` + columns
|
||||
.map((c, i) => `<c r="${colLetter(i + 1)}1" t="inlineStr"><is><t>${esc(c.name)}</t></is></c>`)
|
||||
.join("") + `</row>`;
|
||||
rows.forEach((row, rIdx) => {
|
||||
const r = rIdx + 2;
|
||||
cells += `<row r="${r}">` + row
|
||||
.map((v, i) => {
|
||||
const ref = `${colLetter(i + 1)}${r}`;
|
||||
const t = v === null || v === undefined ? "" : String(v);
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${esc(t)}</t></is></c>`;
|
||||
})
|
||||
.join("") + `</row>`;
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildXlsx(rows: unknown[][], columns: ColumnInfo[]): Uint8Array {
|
||||
const colsXML = buildColsXML(columns);
|
||||
const cells = buildCellsXML(rows, columns);
|
||||
|
||||
const sheet1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${colsXML}<sheetData>${cells}</sheetData></worksheet>`;
|
||||
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const ct = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
|
||||
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
|
||||
const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
|
||||
|
||||
return zipSync({
|
||||
"[Content_Types].xml": strToU8(ct),
|
||||
"_rels/.rels": strToU8(rootRels),
|
||||
"xl/workbook.xml": strToU8(workbook),
|
||||
"xl/_rels/workbook.xml.rels": strToU8(rels),
|
||||
"xl/worksheets/sheet1.xml": strToU8(sheet1),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user