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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user