From 05361eb40de6334af0e1d1ba2d0eebf24f57371a Mon Sep 17 00:00:00 2001 From: lizzzzz777 <72645313+lizzzzz777@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:25:05 +0800 Subject: [PATCH] feat(transfer): persist reusable transfer tasks --- .../transfer/DataTransferDialog.vue | 829 +++++++++++++----- .../components/transfer/TransferTaskTree.vue | 752 ++++++++++++++++ .../__tests__/DataTransferDialog.spec.ts | 19 +- .../__tests__/taskLoadTracker.spec.ts | 39 + .../components/transfer/taskLoadTracker.ts | 21 + .../src/components/ui/CustomContextMenu.vue | 10 +- apps/desktop/src/i18n/locales/en.ts | 32 + apps/desktop/src/i18n/locales/es.ts | 32 + apps/desktop/src/i18n/locales/it.ts | 32 + apps/desktop/src/i18n/locales/ja.ts | 32 + apps/desktop/src/i18n/locales/ko.ts | 32 + apps/desktop/src/i18n/locales/pt-BR.ts | 32 + apps/desktop/src/i18n/locales/zh-CN.ts | 32 + apps/desktop/src/i18n/locales/zh-TW.ts | 32 + .../__tests__/browserAppStateStorage.spec.ts | 88 ++ apps/desktop/src/lib/backend/api.ts | 2 + .../src/lib/backend/browserAppStateStorage.ts | 19 +- apps/desktop/src/lib/backend/http.ts | 8 + apps/desktop/src/lib/backend/safeStorage.ts | 8 +- apps/desktop/src/lib/backend/tauri.ts | 8 + .../__tests__/transferTaskStore.spec.ts | 289 ++++++ apps/desktop/src/stores/transferTaskStore.ts | 447 ++++++++++ apps/desktop/src/types/database.ts | 45 + crates/dbx-core/src/storage.rs | 14 + src-tauri/src/commands/app_settings.rs | 13 + src-tauri/src/lib.rs | 2 + 26 files changed, 2628 insertions(+), 241 deletions(-) create mode 100644 apps/desktop/src/components/transfer/TransferTaskTree.vue create mode 100644 apps/desktop/src/components/transfer/__tests__/taskLoadTracker.spec.ts create mode 100644 apps/desktop/src/components/transfer/taskLoadTracker.ts create mode 100644 apps/desktop/src/lib/backend/__tests__/browserAppStateStorage.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/transferTaskStore.spec.ts create mode 100644 apps/desktop/src/stores/transferTaskStore.ts diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index 131d304e51..758d64b1b5 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -2,9 +2,10 @@ import { computed, ref, watch } from "vue"; import { uuid } from "@/lib/common/utils"; import { useI18n } from "vue-i18n"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { buildTransferObjectSelections } from "./transferSelections"; +import { createTaskLoadTracker } from "./taskLoadTracker"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -17,17 +18,23 @@ import * as api from "@/lib/backend/api"; import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api"; import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds"; import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue"; +import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue"; import type { DatabaseType } from "@/types/database"; +import type { TransferTask, TransferTaskConfig } from "@/types/database"; import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities"; import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport"; import { isSameTransferDatabase, normalizeTransferCatalog } from "@/lib/database/dataTransferSelection"; import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions"; import { useExportTracker } from "@/composables/useExportTracker"; +import { useTransferTaskStore, TransferTaskNameConflictError, nextTransferTaskCopyName } from "@/stores/transferTaskStore"; +import { useToast } from "@/composables/useToast"; import type { CatalogInfo } from "@/types/database"; import { ArrowRightLeft, ArrowLeftRight, Loader2 } from "@lucide/vue"; const { t } = useI18n(); const { startDataTransferTask } = useExportTracker(); +const { toast } = useToast(); +const taskStore = useTransferTaskStore(); const open = defineModel("open", { default: false }); const props = defineProps<{ @@ -122,6 +129,9 @@ const showCrossFamilyViewHint = computed(() => { }); const pendingSourceSchemaPrefill = ref(""); const pendingSelectedTablesPrefill = ref(null); +// Pending object selection for saved-task loading (covers all object kinds, +// unlike the table-only dialog prefill used by sidebar entry points). +const pendingSelectedObjectsPrefill = ref> | null>(null); // Target state const targetConnectionId = ref(""); @@ -138,12 +148,22 @@ const transferMode = ref("append"); const targetTableNameCase = ref("preserve"); const batchSize = ref(1000); const isSubmitting = ref(false); +const showStartConfirm = ref(false); const ownershipDialogOpen = ref(false); const ownershipMissingOwners = ref([]); const ownershipTargetOwner = ref(""); const pendingOwnershipRequest = ref(null); const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null); +// Saved-task state: the form mirrors the active task; a canonical JSON +// snapshot taken at load/save time drives the unsaved-changes check. +const taskTreeRef = ref | null>(null); +const activeTaskId = ref(null); +const savedConfigSnapshot = ref(""); +const showUnsavedConfirm = ref(false); +let pendingDiscardAction: (() => void) | null = null; +const taskLoadTracker = createTaskLoadTracker(); + function connectionType(id: string): DatabaseType | undefined { return store.connections.find((c) => c.id === id)?.db_type; } @@ -177,7 +197,7 @@ const canStart = computed(() => { ); }); -async function loadCatalogs(connectionId: string, side: "source" | "target") { +async function loadCatalogs(connectionId: string, side: "source" | "target", isCancelled: () => boolean = () => false) { if (!connectionId || !isCatalogCapable(connectionId)) { if (side === "source") { sourceCatalogs.value = []; @@ -188,8 +208,12 @@ async function loadCatalogs(connectionId: string, side: "source" | "target") { } return; } + // 竞态防护:请求发出后若用户切换了连接,旧连接的回调必须丢弃, + // 不允许过期结果覆盖新选择(一切以最新选择的连接为准)。 + const isStale = () => isCancelled() || (side === "source" ? sourceConnectionId.value !== connectionId : targetConnectionId.value !== connectionId); try { const catalogs = await api.listDorisCatalogs(connectionId); + if (isStale()) return; if (side === "source") { sourceCatalogs.value = catalogs; sourceCatalog.value = catalogs.length === 1 ? catalogs[0].name : ""; @@ -198,6 +222,7 @@ async function loadCatalogs(connectionId: string, side: "source" | "target") { targetCatalog.value = catalogs.length === 1 ? catalogs[0].name : ""; } } catch { + if (isStale()) return; if (side === "source") { sourceCatalogs.value = []; sourceCatalog.value = ""; @@ -208,13 +233,16 @@ async function loadCatalogs(connectionId: string, side: "source" | "target") { } } -async function loadDatabases(connectionId: string, target: "source" | "target") { +async function loadDatabases(connectionId: string, target: "source" | "target", isCancelled: () => boolean = () => false) { if (!connectionId) return; + // 竞态防护:连接切换后,旧连接的数据库列表回调直接丢弃。 + const isStale = () => isCancelled() || (target === "source" ? sourceConnectionId.value !== connectionId : targetConnectionId.value !== connectionId); try { await store.ensureConnected(connectionId); const config = store.getConfig(connectionId); if (!config) return; const names = isMongoConnection(connectionId) ? databaseOptionsForConnection(await api.mongoListDatabases(connectionId), config) : await fetchNamespaceOptionsForConnection(connectionId, config); + if (isStale()) return; if (target === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; @@ -223,18 +251,26 @@ async function loadDatabases(connectionId: string, target: "source" | "target") targetDatabase.value = names.length === 1 ? names[0] : ""; } } catch { + if (isStale()) return; if (target === "source") sourceDatabases.value = []; else targetDatabases.value = []; } } -async function loadDatabasesForCatalog(connectionId: string, catalog: string, target: "source" | "target") { +async function loadDatabasesForCatalog(connectionId: string, catalog: string, target: "source" | "target", isCancelled: () => boolean = () => false) { if (!connectionId || !catalog) return; + // 竞态防护:连接或 catalog 切换后,旧请求的数据库列表回调直接丢弃。 + const isStale = () => { + const currentConnection = target === "source" ? sourceConnectionId.value : targetConnectionId.value; + const currentCatalog = target === "source" ? sourceCatalog.value : targetCatalog.value; + return isCancelled() || currentConnection !== connectionId || currentCatalog !== catalog; + }; try { await store.ensureConnected(connectionId); const config = store.getConfig(connectionId); if (!config) return; const names = await fetchCatalogNamespaceOptions(connectionId, catalog, config); + if (isStale()) return; if (target === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; @@ -243,14 +279,22 @@ async function loadDatabasesForCatalog(connectionId: string, catalog: string, ta targetDatabase.value = names.length === 1 ? names[0] : ""; } } catch { + if (isStale()) return; if (target === "source") sourceDatabases.value = []; else targetDatabases.value = []; } } -async function loadSchemas(connectionId: string, database: string, side: "source" | "target", preferredSchema = "") { +async function loadSchemas(connectionId: string, database: string, side: "source" | "target", preferredSchema = "", isCancelled: () => boolean = () => false) { if (!connectionId || !database) return; + // 竞态防护:连接或数据库切换后,旧请求的 schema 列表回调直接丢弃。 + const isStale = () => { + const currentConnection = side === "source" ? sourceConnectionId.value : targetConnectionId.value; + const currentDatabase = side === "source" ? sourceDatabase.value : targetDatabase.value; + return isCancelled() || currentConnection !== connectionId || currentDatabase !== database; + }; if (isMongoConnection(connectionId)) { + if (isStale()) return; if (side === "source") { sourceSchemas.value = []; sourceSchema.value = database; @@ -262,6 +306,7 @@ async function loadSchemas(connectionId: string, database: string, side: "source } try { const schemas = await api.listSchemas(connectionId, database); + if (isStale()) return; const selected = preferredSchema && schemas.includes(preferredSchema) ? preferredSchema : schemas.includes("public") ? "public" : (schemas[0] ?? ""); if (side === "source") { sourceSchemas.value = schemas; @@ -271,6 +316,7 @@ async function loadSchemas(connectionId: string, database: string, side: "source targetSchema.value = selected; } } catch { + if (isStale()) return; if (side === "source") { sourceSchemas.value = []; sourceSchema.value = ""; @@ -293,49 +339,81 @@ function applyPendingTableSelection() { pendingSelectedTablesPrefill.value = null; } -async function loadObjects() { - if (!sourceConnectionId.value || !sourceDatabase.value) { +/** Applies a saved task's object selection, dropping objects missing from the source. */ +function applyPendingObjectSelection() { + const pending = pendingSelectedObjectsPrefill.value; + if (!pending) return; + const next: Partial>> = { ...selectedObjects.value }; + for (const [kind, names] of Object.entries(pending)) { + const available = objectGroups.value[kind as TransferObjectKind] ?? []; + const chosen = new Set(available.filter((name) => names.includes(name))); + if (chosen.size > 0) next[kind as TransferObjectKind] = chosen; + } + selectedObjects.value = next; + pendingSelectedObjectsPrefill.value = null; +} + +async function loadObjects(isCancelled: () => boolean = () => false) { + const connectionId = sourceConnectionId.value; + const database = sourceDatabase.value; + if (!connectionId || !database) { objectGroups.value = {}; return; } + // 竞态防护:捕获发起时的上下文快照,加载期间用户切换连接/数据库/Schema 后, + // 旧请求的对象列表回调直接丢弃,不覆盖新选择对应的状态。 + const catalog = sourceCatalog.value || undefined; + const schemaValue = sourceSchema.value; + const isStale = () => isCancelled() || sourceConnectionId.value !== connectionId || sourceDatabase.value !== database || (sourceCatalog.value || undefined) !== catalog || sourceSchema.value !== schemaValue; loadingObjects.value = true; try { - if (isMongoConnection(sourceConnectionId.value)) { - const collections = await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value); + if (isMongoConnection(connectionId)) { + const collections = await api.mongoListCollections(connectionId, database); + if (isStale()) return; objectGroups.value = { TABLE: collections.map((c) => c.name) }; applyPendingTableSelection(); + applyPendingObjectSelection(); return; } - const config = store.getConfig(sourceConnectionId.value); + const config = store.getConfig(connectionId); const needsSchema = isSchemaAware(config?.db_type); - const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value; - const catalog = sourceCatalog.value || undefined; + const schema = needsSchema && schemaValue ? schemaValue : database; const kinds = transferObjectKindsForDatabase(config?.db_type); const groups: Partial> = {}; for (const kind of kinds) { try { if (kind === "TABLE") { - const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog); + const tables = await api.listTables(connectionId, database, schema, undefined, undefined, undefined, undefined, catalog); groups.TABLE = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name); } else { - const objects = await api.listObjects(sourceConnectionId.value, sourceDatabase.value, schema, [kind], undefined, undefined, undefined, catalog); + const objects = await api.listObjects(connectionId, database, schema, [kind], undefined, undefined, undefined, catalog); groups[kind] = objects.map((o) => o.name); } } catch { groups[kind] = []; } } + if (isStale()) return; objectGroups.value = groups; applyPendingTableSelection(); + applyPendingObjectSelection(); } catch { - objectGroups.value = {}; + if (!isStale()) objectGroups.value = {}; } finally { - loadingObjects.value = false; + if (!isStale()) loadingObjects.value = false; } } const skipSourceWatch = ref(false); const skipTargetWatch = ref(false); +// One-shot watcher suppression used when applying a saved task: every flag is +// consumed by its watcher so task loading can drive the form deterministically +// with explicit awaits instead of racing the async watcher chain. +const skipSourceCatalogWatch = ref(false); +const skipSourceDatabaseWatch = ref(false); +const skipSourceSchemaWatch = ref(false); +const skipTargetCatalogWatch = ref(false); +const skipTargetDatabaseWatch = ref(false); watch(sourceConnectionId, async (id) => { if (skipSourceWatch.value) { @@ -351,6 +429,8 @@ watch(sourceConnectionId, async (id) => { pendingSelectedTablesPrefill.value = null; if (isCatalogCapable(id)) { await loadCatalogs(id, "source"); + // await 期间连接可能已被切换,旧链直接放弃,后续加载由新连接的 watcher 负责 + if (sourceConnectionId.value !== id) return; if (sourceCatalog.value) { await loadDatabasesForCatalog(id, sourceCatalog.value, "source"); } @@ -360,6 +440,10 @@ watch(sourceConnectionId, async (id) => { }); watch(sourceCatalog, async (catalog) => { + if (skipSourceCatalogWatch.value) { + skipSourceCatalogWatch.value = false; + return; + } if (!sourceConnectionId.value) return; sourceDatabase.value = ""; objectGroups.value = {}; @@ -370,6 +454,10 @@ watch(sourceCatalog, async (catalog) => { }); watch(sourceDatabase, async (db) => { + if (skipSourceDatabaseWatch.value) { + skipSourceDatabaseWatch.value = false; + return; + } if (db) { const config = store.getConfig(sourceConnectionId.value); if (namespaceOptionsAreSchemas(config)) { @@ -386,7 +474,13 @@ watch(sourceDatabase, async (db) => { } }); -watch(sourceSchema, () => loadObjects()); +watch(sourceSchema, () => { + if (skipSourceSchemaWatch.value) { + skipSourceSchemaWatch.value = false; + return; + } + loadObjects(); +}); watch(targetConnectionId, async (id) => { if (skipTargetWatch.value) { @@ -401,6 +495,8 @@ watch(targetConnectionId, async (id) => { pendingTargetSchemaPrefill.value = ""; if (isCatalogCapable(id)) { await loadCatalogs(id, "target"); + // await 期间连接可能已被切换,旧链直接放弃,后续加载由新连接的 watcher 负责 + if (targetConnectionId.value !== id) return; if (targetCatalog.value) { await loadDatabasesForCatalog(id, targetCatalog.value, "target"); } @@ -410,6 +506,10 @@ watch(targetConnectionId, async (id) => { }); watch(targetCatalog, async (catalog) => { + if (skipTargetCatalogWatch.value) { + skipTargetCatalogWatch.value = false; + return; + } if (!targetConnectionId.value) return; targetDatabase.value = ""; targetSchemas.value = []; @@ -420,6 +520,10 @@ watch(targetCatalog, async (catalog) => { }); watch(targetDatabase, async (db) => { + if (skipTargetDatabaseWatch.value) { + skipTargetDatabaseWatch.value = false; + return; + } if (db) { const config = store.getConfig(targetConnectionId.value); if (namespaceOptionsAreSchemas(config)) { @@ -438,6 +542,7 @@ watch( open, async (val) => { if (val) { + void taskStore.initFromStorage(); resetState(); pendingSourceSchemaPrefill.value = props.prefillSchema ?? ""; pendingSelectedTablesPrefill.value = props.prefillTables?.length ? [...props.prefillTables] : null; @@ -476,7 +581,8 @@ watch( { immediate: true }, ); -function resetState() { +function resetState(cancelTaskLoad = true) { + if (cancelTaskLoad) taskLoadTracker.cancel(); sourceConnectionId.value = ""; sourceCatalog.value = ""; sourceCatalogs.value = []; @@ -486,6 +592,7 @@ function resetState() { sourceSchema.value = ""; objectGroups.value = {}; selectedObjects.value = {}; + loadingObjects.value = false; pendingSourceSchemaPrefill.value = ""; pendingSelectedTablesPrefill.value = null; objectSearch.value = ""; @@ -502,11 +609,15 @@ function resetState() { targetTableNameCase.value = "preserve"; batchSize.value = 1000; isSubmitting.value = false; + showStartConfirm.value = false; ownershipDialogOpen.value = false; ownershipMissingOwners.value = []; ownershipTargetOwner.value = ""; pendingOwnershipRequest.value = null; pendingOwnershipRefresh.value = null; + pendingSelectedObjectsPrefill.value = null; + activeTaskId.value = null; + savedConfigSnapshot.value = ""; } async function startTransfer() { @@ -595,244 +706,461 @@ function resolveOwnershipDecision(policy: api.TransferOwnershipPolicy | null) { function getConnectionName(id: string) { return store.connections.find((c) => c.id === id)?.name ?? id; } + +// --------------------------------------------------------------------------- +// Saved transfer tasks (left tree panel) +// --------------------------------------------------------------------------- + +const activeTaskName = computed(() => (activeTaskId.value ? (taskStore.getTask(activeTaskId.value)?.name ?? "") : "")); + +/** Builds a serializable config from the current form state. */ +function currentConfig(): TransferTaskConfig { + const objects: TransferTaskConfig["objects"] = {}; + for (const [kind, names] of Object.entries(selectedObjects.value)) { + if (names && names.size > 0) objects[kind as TransferObjectKind] = [...names]; + } + return { + sourceConnectionId: sourceConnectionId.value, + sourceCatalog: normalizeTransferCatalog(sourceCatalog.value, sourceCatalogs.value) || undefined, + sourceDatabase: sourceDatabase.value, + sourceSchema: sourceSchema.value || undefined, + targetConnectionId: targetConnectionId.value, + targetCatalog: normalizeTransferCatalog(targetCatalog.value, targetCatalogs.value) || undefined, + targetDatabase: targetDatabase.value, + targetSchema: targetSchema.value || undefined, + objects, + content: transferContent.value, + mode: transferMode.value, + targetTableNameCase: targetTableNameCase.value, + batchSize: batchSize.value, + }; +} + +/** Canonical serialization (sorted kinds/names) for stable dirty comparison. */ +function configSnapshot(config: TransferTaskConfig) { + const orderedObjects: Record = {}; + for (const kind of Object.keys(config.objects).sort()) { + orderedObjects[kind] = [...(config.objects[kind as TransferObjectKind] ?? [])].sort(); + } + return JSON.stringify({ ...config, objects: orderedObjects }); +} + +const formHasContent = computed(() => !!sourceConnectionId.value || !!targetConnectionId.value); +const isConfigDirty = computed(() => !!activeTaskId.value && configSnapshot(currentConfig()) !== savedConfigSnapshot.value); +const needsDiscardConfirm = computed(() => isConfigDirty.value || (!activeTaskId.value && formHasContent.value)); +const canSaveConfig = computed(() => !!sourceConnectionId.value && !!sourceDatabase.value && !!targetConnectionId.value && !!targetDatabase.value); + +/** Applies a saved task to the form, loading catalogs/databases/schemas/objects with explicit awaits. */ +async function loadTaskIntoForm(task: TransferTask) { + const taskLoadToken = taskLoadTracker.begin(task.id); + const isTaskLoadStale = () => !taskLoadTracker.isCurrent(taskLoadToken, activeTaskId.value); + const config = task.config; + resetState(false); + activeTaskId.value = task.id; + transferContent.value = config.content; + transferMode.value = config.mode; + targetTableNameCase.value = config.targetTableNameCase; + batchSize.value = config.batchSize; + pendingSelectedObjectsPrefill.value = Object.keys(config.objects).length > 0 ? JSON.parse(JSON.stringify(config.objects)) : null; + + skipSourceWatch.value = true; + sourceConnectionId.value = config.sourceConnectionId; + if (isCatalogCapable(config.sourceConnectionId)) { + await loadCatalogs(config.sourceConnectionId, "source", isTaskLoadStale); + if (isTaskLoadStale()) return; + if (config.sourceCatalog && sourceCatalog.value !== config.sourceCatalog) { + skipSourceCatalogWatch.value = true; + sourceCatalog.value = config.sourceCatalog; + } + if (sourceCatalog.value) { + await loadDatabasesForCatalog(config.sourceConnectionId, sourceCatalog.value, "source", isTaskLoadStale); + if (isTaskLoadStale()) return; + } + } else { + await loadDatabases(config.sourceConnectionId, "source", isTaskLoadStale); + if (isTaskLoadStale()) return; + } + if (sourceDatabase.value !== config.sourceDatabase) { + skipSourceDatabaseWatch.value = true; + sourceDatabase.value = config.sourceDatabase; + } + const sourceConfig = store.getConfig(config.sourceConnectionId); + if (namespaceOptionsAreSchemas(sourceConfig)) { + sourceSchemas.value = []; + sourceSchema.value = config.sourceDatabase; + } else if (isSchemaAware(sourceConfig?.db_type)) { + await loadSchemas(config.sourceConnectionId, config.sourceDatabase, "source", config.sourceSchema ?? "", isTaskLoadStale); + if (isTaskLoadStale()) return; + } else { + sourceSchema.value = config.sourceDatabase; + } + // The schema watcher may trigger a concurrent loadObjects; both converge on + // the same groups and the pending selection is consumed by the first one. + await loadObjects(isTaskLoadStale); + if (isTaskLoadStale()) return; + + skipTargetWatch.value = true; + targetConnectionId.value = config.targetConnectionId; + if (isCatalogCapable(config.targetConnectionId)) { + await loadCatalogs(config.targetConnectionId, "target", isTaskLoadStale); + if (isTaskLoadStale()) return; + if (config.targetCatalog && targetCatalog.value !== config.targetCatalog) { + skipTargetCatalogWatch.value = true; + targetCatalog.value = config.targetCatalog; + } + if (targetCatalog.value) { + await loadDatabasesForCatalog(config.targetConnectionId, targetCatalog.value, "target", isTaskLoadStale); + if (isTaskLoadStale()) return; + } + } else { + await loadDatabases(config.targetConnectionId, "target", isTaskLoadStale); + if (isTaskLoadStale()) return; + } + if (targetDatabase.value !== config.targetDatabase) { + skipTargetDatabaseWatch.value = true; + targetDatabase.value = config.targetDatabase; + } + const targetConfig = store.getConfig(config.targetConnectionId); + if (namespaceOptionsAreSchemas(targetConfig)) { + targetSchemas.value = []; + targetSchema.value = config.targetDatabase; + } else if (isSchemaAware(targetConfig?.db_type)) { + await loadSchemas(config.targetConnectionId, config.targetDatabase, "target", config.targetSchema ?? "", isTaskLoadStale); + if (isTaskLoadStale()) return; + } else { + targetSchema.value = config.targetDatabase; + } + + if (isTaskLoadStale()) return; + savedConfigSnapshot.value = configSnapshot(currentConfig()); +} + +/** Runs the action immediately, or asks for confirmation when the form has unsaved changes. */ +function requestDiscardableAction(action: () => void) { + if (needsDiscardConfirm.value) { + pendingDiscardAction = action; + showUnsavedConfirm.value = true; + return; + } + action(); +} + +function confirmDiscardChanges() { + showUnsavedConfirm.value = false; + const action = pendingDiscardAction; + pendingDiscardAction = null; + action?.(); +} + +function onSelectTask(task: TransferTask) { + if (task.id === activeTaskId.value && !isConfigDirty.value) return; + requestDiscardableAction(() => { + void loadTaskIntoForm(task); + }); +} + +function onNewBlank() { + requestDiscardableAction(() => resetState()); +} + +/** Tree cleared the selection because the active task was deleted: reset the form so no stale config lingers as "unsaved changes". */ +function onSelectedTaskIdUpdate(id: string | null) { + if (id === null && activeTaskId.value) { + resetState(); + return; + } + activeTaskId.value = id; +} + +// ---- start confirmation (guards the only run entry: the footer button) ---- + +/** Number of objects currently selected across all kinds. */ +const selectedObjectCount = computed(() => Object.values(selectedObjects.value).reduce((total, names) => total + (names?.size ?? 0), 0)); + +const startConfirmSource = computed(() => `${getConnectionName(sourceConnectionId.value)}.${sourceDatabase.value}`); +const startConfirmTarget = computed(() => `${getConnectionName(targetConnectionId.value)}.${targetDatabase.value}`); + +/** Opens the confirmation dialog before starting a transfer. */ +function requestStartTransfer() { + if (!canStart.value || isSubmitting.value) return; + showStartConfirm.value = true; +} + +/** Confirmed: close the prompt and run the normal start flow. */ +function confirmStartTransfer() { + showStartConfirm.value = false; + void startTransfer(); +} + +/** Saves the form into the active task, or creates a new one and starts its rename. */ +async function saveConfigTask() { + if (!canSaveConfig.value) return; + const config = currentConfig(); + try { + const existing = activeTaskId.value ? taskStore.getTask(activeTaskId.value) : undefined; + if (existing) { + await taskStore.saveTask({ id: existing.id, name: existing.name, config }); + } else { + const rootTasks = taskStore.listTasks(undefined); + const defaultName = t("transfer.tasks.newTaskDefault"); + const takenNames = new Set(rootTasks.map((task) => task.name)); + const takenKeys = new Set(rootTasks.map((task) => task.name.toLocaleLowerCase())); + const name = takenKeys.has(defaultName.toLocaleLowerCase()) ? nextTransferTaskCopyName(defaultName, takenNames) : defaultName; + const task = await taskStore.saveTask({ name, config }); + activeTaskId.value = task.id; + taskTreeRef.value?.startRenameTask(task); + } + savedConfigSnapshot.value = configSnapshot(currentConfig()); + toast(t("transfer.taskSaved"), 2000); + } catch (error) { + const message = error instanceof TransferTaskNameConflictError ? t("transfer.tasks.nameConflict", { name: error.entryName }) : ((error as Error)?.message ?? String(error)); + toast(t("transfer.tasks.saveFailed", { message }), 5000); + } +}