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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
2026-08-08 00:09:36 +08:00
committed by GitHub
parent 9fb3222956
commit 32a7b852ec
59 changed files with 4068 additions and 572 deletions
+185 -94
View File
@@ -5,16 +5,24 @@ import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
import {
detectPgTools,
dbSync,
getSchemas,
detectMysqlTools,
mysqlSync,
sqliteSync,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections);
@@ -27,6 +35,13 @@ export function SyncPage() {
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
const sourceConnection = connections.find(
(c) => c.id === sourceConnectionId,
);
const dbType: DbType | null = sourceConnection?.db_type ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
@@ -43,23 +58,44 @@ export function SyncPage() {
}
}, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type
useEffect(() => {
setCheckingTools(true);
setPgToolStatus(null);
setMysqlToolStatus(null);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
}, []);
if (isPg) {
setCheckingTools(true);
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
} else if (isMysql) {
setCheckingTools(true);
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
}
}, [isPg, isMysql]);
// Fetch schemas from the source connection when it changes
useEffect(() => {
@@ -68,10 +104,31 @@ export function SyncPage() {
setSchema("");
return;
}
if (!isPg && !isMysql) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [sourceConnectionId]);
}, [sourceConnectionId, isPg, isMysql]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
@@ -82,35 +139,61 @@ export function SyncPage() {
notify("Source and target must be different", "error");
return;
}
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
pendingJobRef.current = jobId;
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
if (!dbType) {
notify("Unable to determine database type for sync", "error");
return;
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const toolsBundled =
toolStatus?.pg_dump_source === "bundled" &&
toolStatus?.pg_restore_source === "bundled";
const options = {
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
dbType,
};
await runWithProgress("sync", () => {
if (isPg) return dbSync(options);
if (isMysql) return mysqlSync(options);
return sqliteSync(options);
});
}, [
sourceConnectionId,
targetConnectionId,
dbType,
isPg,
isMysql,
schema,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus &&
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
: isMysql
? mysqlToolStatus &&
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled" &&
pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const postgresqlConnections = connections.filter(
(c) => c.db_type === "postgresql",
);
const checkingMessage = isPg
? "Checking for pg_dump / pg_restore..."
: isMysql
? "Checking for mysqldump / mysql..."
: null;
const targetConnections = dbType
? connections.filter((c) => c.db_type === dbType)
: [];
return (
<div className="flex flex-col h-full">
@@ -119,7 +202,7 @@ export function SyncPage() {
<ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted">
Transfer data between PostgreSQL databases via pipe
Transfer data between databases via pipe
</span>
</div>
@@ -127,10 +210,10 @@ export function SyncPage() {
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump / pg_restore...
{checkingMessage}
</p>
</div>
)}
@@ -138,19 +221,27 @@ export function SyncPage() {
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found
{isPg
? "PostgreSQL tools not found"
: "MySQL tools not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Both pg_dump and pg_restore are required for
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
database sync.
</p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{!toolStatus?.pg_dump_found && (
{isPg && !pgToolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li>
)}
{!toolStatus?.pg_restore_found && (
{isPg && !pgToolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
<li>mysqldump is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li>
)}
</ul>
</div>
)}
@@ -168,24 +259,21 @@ export function SyncPage() {
</label>
<select
value={sourceConnectionId}
onChange={(e) =>
onChange={(e) => {
setSourceConnectionId(
e.target.value,
)
}
);
setTargetConnectionId("");
}}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select source...
</option>
{postgresqlConnections.map((c) => (
{connections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
targetConnectionId
}
>
{c.name}
</option>
@@ -204,18 +292,20 @@ export function SyncPage() {
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
Select target...
{sourceConnectionId
? "Select target..."
: "Select a source first"}
</option>
{postgresqlConnections.map((c) => (
{targetConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
sourceConnectionId
c.id === sourceConnectionId
}
>
{c.name}
@@ -226,39 +316,41 @@ export function SyncPage() {
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
))}
</select>
</div>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text">
{postgresqlConnections.find(
{connections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
@@ -268,7 +360,7 @@ export function SyncPage() {
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{postgresqlConnections.find(
{connections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
@@ -328,5 +420,4 @@ export function SyncPage() {
</div>
</div>
);
}
}