fix(diff): scroll data compare results in a plain element

This commit is contained in:
zipg
2026-09-21 16:33:47 +08:00
committed by GitHub
parent e97f1fcd13
commit 916b62287a
5 changed files with 358 additions and 333 deletions
@@ -1068,381 +1068,383 @@ onBeforeUnmount(() => {
</DialogTitle>
</DialogHeader>
<fieldset :disabled="executionLocked" class="flex-1 min-h-0 min-w-0 overflow-auto space-y-4 py-2">
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
<div class="space-y-2 rounded-lg border border-blue-500/35 bg-blue-500/5 p-3">
<div class="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-500/15 text-[11px] font-semibold">S</span>
{{ t("diff.source") }}
</div>
<ConnectionTreeSelect
v-model="sourceConnectionId"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
:search-placeholder="t('diff.searchConnection')"
:empty-text="t('common.noResults')"
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-model="sourceDatabase"
:options="sourceDatabases"
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || executionLocked || !sourceDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-if="sourceSchemas.length"
v-model="sourceSchema"
:options="sourceSchemas"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<TableMultiSelect
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
v-model="sourceTableSelection"
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
:disabled="comparing || executionLocked"
/>
</div>
<div class="flex items-center pt-6">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked" @click="swapSourceTarget">
<ArrowLeftRight class="w-3.5 h-3.5" />
</Button>
</div>
<div class="space-y-2 rounded-lg border border-emerald-500/35 bg-emerald-500/5 p-3">
<div class="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[11px] font-semibold">T</span>
{{ t("diff.target") }}
</div>
<ConnectionTreeSelect
v-model="targetConnectionId"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
:search-placeholder="t('diff.searchConnection')"
:empty-text="t('common.noResults')"
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-model="targetDatabase"
:options="targetDatabases"
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || executionLocked || !targetDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-if="targetSchemas.length"
v-model="targetSchema"
:options="targetSchemas"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<div v-if="!isBatchCompare" class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.targetTable") }}</Label>
<SearchableSelect
v-model="targetTable"
:options="targetTables"
:placeholder="t('dataCompare.selectTable')"
:search-placeholder="t('dataCompare.searchTable')"
:empty-text="t('common.noResults')"
<div class="flex-1 min-h-0 min-w-0 overflow-auto">
<fieldset :disabled="executionLocked" class="space-y-4 py-2">
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
<div class="space-y-2 rounded-lg border border-blue-500/35 bg-blue-500/5 p-3">
<div class="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-500/15 text-[11px] font-semibold">S</span>
{{ t("diff.source") }}
</div>
<ConnectionTreeSelect
v-model="sourceConnectionId"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
:search-placeholder="t('diff.searchConnection')"
:empty-text="t('common.noResults')"
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-model="sourceDatabase"
:options="sourceDatabases"
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || executionLocked || !sourceDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-if="sourceSchemas.length"
v-model="sourceSchema"
:options="sourceSchemas"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<TableMultiSelect
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
v-model="sourceTableSelection"
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
:disabled="comparing || executionLocked"
/>
</div>
<div v-else class="space-y-2 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("dataCompare.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{ t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length }) }}
<div class="flex items-center pt-6">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked" @click="swapSourceTarget">
<ArrowLeftRight class="w-3.5 h-3.5" />
</Button>
</div>
<div class="space-y-2 rounded-lg border border-emerald-500/35 bg-emerald-500/5 p-3">
<div class="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[11px] font-semibold">T</span>
{{ t("diff.target") }}
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("dataCompare.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
<ConnectionTreeSelect
v-model="targetConnectionId"
:disabled="comparing || executionLocked"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
:search-placeholder="t('diff.searchConnection')"
:empty-text="t('common.noResults')"
trigger-class="dbx-diff-connection-trigger h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-model="targetDatabase"
:options="targetDatabases"
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="comparing || executionLocked || !targetDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<SearchableSelect
v-if="targetSchemas.length"
v-model="targetSchema"
:options="targetSchemas"
:disabled="comparing || executionLocked"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<div v-if="!isBatchCompare" class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.targetTable") }}</Label>
<SearchableSelect
v-model="targetTable"
:options="targetTables"
:placeholder="t('dataCompare.selectTable')"
:search-placeholder="t('dataCompare.searchTable')"
:empty-text="t('common.noResults')"
:disabled="comparing || executionLocked"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
</div>
<div v-if="compareTasksPreview.length" class="max-h-36 overflow-auto rounded border bg-muted/20">
<div v-for="task in compareTasksPreview" :key="`${task.sourceTable}:${task.targetTable}`" class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0">
<span class="truncate font-mono">{{ task.sourceTable }}</span>
<span class="text-muted-foreground">→</span>
<span class="truncate font-mono" :class="task.matched ? '' : 'text-destructive'">
{{ task.targetTable || t("dataCompare.targetTableMissing", { table: task.sourceTable }) }}
</span>
<div v-else class="space-y-2 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("dataCompare.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{ t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length }) }}
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("dataCompare.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
</div>
<div v-if="compareTasksPreview.length" class="max-h-36 overflow-auto rounded border bg-muted/20">
<div v-for="task in compareTasksPreview" :key="`${task.sourceTable}:${task.targetTable}`" class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0">
<span class="truncate font-mono">{{ task.sourceTable }}</span>
<span class="text-muted-foreground">→</span>
<span class="truncate font-mono" :class="task.matched ? '' : 'text-destructive'">
{{ task.targetTable || t("dataCompare.targetTableMissing", { table: task.sourceTable }) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<span v-if="singleKeyColumnRow" class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium" :class="singleKeyColumnRow.manual ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">
{{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }}
</span>
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
<div v-if="!selectedSourceTableNames.length" class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsSelectTableHint") }}
</div>
<!-- Single table: pick match columns straight from the real database columns. -->
<div v-else-if="singleKeyColumnRow" class="space-y-1">
<CompareKeyColumnsSelect
:model-value="singleKeyColumnRow.columns"
:columns="columnsForTable(singleKeyColumnRow.table)"
:loading="singleKeyColumnRow.loading"
:disabled="comparing || executionLocked"
@update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)"
/>
<div class="text-[11px]" data-key-column-hint :class="singleKeyColumnRow.error || singleKeyColumnRow.status === 'missing' ? 'text-destructive' : 'text-muted-foreground'">
{{ singleKeyColumnRow.hint }}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<span v-if="singleKeyColumnRow" class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium" :class="singleKeyColumnRow.manual ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">
{{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }}
</span>
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
</div>
<!-- Batch compare: every table keeps its own match columns. -->
<div v-else class="space-y-1">
<div class="rounded-lg border">
<div class="border-b bg-muted/20 px-2 py-1 text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsPerTableHint") }}
<div v-if="!selectedSourceTableNames.length" class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsSelectTableHint") }}
</div>
<!-- Single table: pick match columns straight from the real database columns. -->
<div v-else-if="singleKeyColumnRow" class="space-y-1">
<CompareKeyColumnsSelect
:model-value="singleKeyColumnRow.columns"
:columns="columnsForTable(singleKeyColumnRow.table)"
:loading="singleKeyColumnRow.loading"
:disabled="comparing || executionLocked"
@update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)"
/>
<div class="text-[11px]" data-key-column-hint :class="singleKeyColumnRow.error || singleKeyColumnRow.status === 'missing' ? 'text-destructive' : 'text-muted-foreground'">
{{ singleKeyColumnRow.hint }}
</div>
<div class="max-h-56 divide-y overflow-auto">
<div v-for="row in keyColumnRows" :key="row.table">
<button type="button" class="dbx-compare-key-table-row flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-muted/40" :data-key-column-table="row.table" @click="toggleKeyColumnTable(row.table)">
<ChevronDown v-if="expandedKeyColumnTable === row.table" class="h-3.5 w-3.5 shrink-0" />
<ChevronRight v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono">{{ row.table }}</span>
<span class="min-w-0 max-w-[45%] shrink-0 truncate font-mono" :class="row.columns.length > 0 ? (row.manual ? 'text-foreground' : 'text-muted-foreground') : 'text-destructive'">
{{ row.loading ? t("dataCompare.keyColumnsLoading") : row.columns.length > 0 ? row.columns.join(", ") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="row.status === 'manual' ? 'bg-primary/15 text-primary' : row.status === 'auto' ? 'bg-muted text-muted-foreground' : 'bg-destructive/15 text-destructive'">
{{ row.status === "manual" ? t("dataCompare.keyColumnsStatusManual") : row.status === "auto" ? t("dataCompare.keyColumnsStatusAuto") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
</button>
<div v-if="expandedKeyColumnTable === row.table" class="space-y-1 border-t bg-muted/10 px-2 py-2">
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing || executionLocked" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<span data-key-column-hint :class="row.error || (row.status === 'missing' && row.matched) ? 'text-destructive' : 'text-muted-foreground'">{{ row.hint }}</span>
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(row.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
<!-- Batch compare: every table keeps its own match columns. -->
<div v-else class="space-y-1">
<div class="rounded-lg border">
<div class="border-b bg-muted/20 px-2 py-1 text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsPerTableHint") }}
</div>
<div class="max-h-56 divide-y overflow-auto">
<div v-for="row in keyColumnRows" :key="row.table">
<button type="button" class="dbx-compare-key-table-row flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-muted/40" :data-key-column-table="row.table" @click="toggleKeyColumnTable(row.table)">
<ChevronDown v-if="expandedKeyColumnTable === row.table" class="h-3.5 w-3.5 shrink-0" />
<ChevronRight v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono">{{ row.table }}</span>
<span class="min-w-0 max-w-[45%] shrink-0 truncate font-mono" :class="row.columns.length > 0 ? (row.manual ? 'text-foreground' : 'text-muted-foreground') : 'text-destructive'">
{{ row.loading ? t("dataCompare.keyColumnsLoading") : row.columns.length > 0 ? row.columns.join(", ") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="row.status === 'manual' ? 'bg-primary/15 text-primary' : row.status === 'auto' ? 'bg-muted text-muted-foreground' : 'bg-destructive/15 text-destructive'">
{{ row.status === "manual" ? t("dataCompare.keyColumnsStatusManual") : row.status === "auto" ? t("dataCompare.keyColumnsStatusAuto") : t("dataCompare.keyColumnsNeedsSelection") }}
</span>
</button>
<div v-if="expandedKeyColumnTable === row.table" class="space-y-1 border-t bg-muted/10 px-2 py-2">
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing || executionLocked" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<span data-key-column-hint :class="row.error || (row.status === 'missing' && row.matched) ? 'text-destructive' : 'text-muted-foreground'">{{ row.hint }}</span>
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(row.table)">
{{ t("dataCompare.keyColumnsResetAuto") }}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="comparing" class="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin text-primary" />
<span>{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
</div>
<div v-if="hasResults" class="space-y-3">
<div class="rounded-lg border p-3 text-sm space-y-2">
<div>{{ summary }}</div>
<div class="text-xs text-muted-foreground">{{ selectedSummary }}</div>
<div v-if="comparing" class="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin text-primary" />
<span>{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
</div>
<div class="rounded-lg border p-3 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showAdded ? 'border-primary' : ''" @click="showAdded = !showAdded"> {{ t("diff.added") }} · {{ totalAdded }} </Button>
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showRemoved ? 'border-primary' : ''" @click="showRemoved = !showRemoved"> {{ t("diff.removed") }} · {{ totalRemoved }} </Button>
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showModified ? 'border-primary' : ''" @click="showModified = !showModified"> {{ t("diff.modified") }} · {{ totalModified }} </Button>
<span class="flex-1" />
<Select v-model="detailPreviewLimit">
<SelectTrigger class="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="limit in PREVIEW_LIMIT_OPTIONS" :key="limit" :value="String(limit)">
{{ t("dataCompare.previewLimitOption", { count: limit }) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="hasResults" class="space-y-3">
<div class="rounded-lg border p-3 text-sm space-y-2">
<div>{{ summary }}</div>
<div class="text-xs text-muted-foreground">{{ selectedSummary }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('added', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.added") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('removed', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.removed") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('modified', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.modified") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="clearAllSelections">
{{ t("dataCompare.clearSelection") }}
</Button>
<div class="rounded-lg border p-3 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showAdded ? 'border-primary' : ''" @click="showAdded = !showAdded"> {{ t("diff.added") }} · {{ totalAdded }} </Button>
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showRemoved ? 'border-primary' : ''" @click="showRemoved = !showRemoved"> {{ t("diff.removed") }} · {{ totalRemoved }} </Button>
<Button size="sm" variant="outline" class="h-7 text-xs" :class="showModified ? 'border-primary' : ''" @click="showModified = !showModified"> {{ t("diff.modified") }} · {{ totalModified }} </Button>
<span class="flex-1" />
<Select v-model="detailPreviewLimit">
<SelectTrigger class="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="limit in PREVIEW_LIMIT_OPTIONS" :key="limit" :value="String(limit)">
{{ t("dataCompare.previewLimitOption", { count: limit }) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('added', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.added") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('removed', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.removed") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('modified', true)">
{{ t("dataCompare.selectAllKind", { kind: t("diff.modified") }) }}
</Button>
<Button size="sm" variant="outline" class="h-7 text-xs" @click="clearAllSelections">
{{ t("dataCompare.clearSelection") }}
</Button>
</div>
</div>
</div>
<div class="rounded-lg border overflow-hidden">
<div class="max-h-64 overflow-auto">
<table class="w-full text-xs">
<thead class="bg-muted sticky top-0 z-10">
<tr>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.table") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("dataCompare.targetTable") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.status") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.details") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in batchResults" :key="`${item.sourceTable}:${item.targetTable}`" class="border-t">
<td class="px-3 py-2 align-top font-mono">{{ item.sourceTable }}</td>
<td class="px-3 py-2 align-top font-mono text-muted-foreground">{{ item.targetTable }}</td>
<td class="px-3 py-2 align-top">
<span class="inline-flex rounded px-2 py-0.5 text-[11px]" :class="resultStatusClass(item.status)">
{{ resultStatusLabel(item.status) }}
</span>
</td>
<td class="px-3 py-2 align-top text-muted-foreground">
<div v-if="item.status === 'error'" class="text-destructive">{{ item.error }}</div>
<template v-else>
<div>
{{
t("dataCompare.summary", {
added: item.added,
removed: item.removed,
modified: item.modified,
})
}}
</div>
<div class="mt-1">
{{
t("dataCompare.rowCounts", {
source: item.sourceRowCount,
target: item.targetRowCount,
})
}}
</div>
<div class="mt-1">
{{ t("dataCompare.keyColumnsInline", { columns: item.keyColumns.join(", ") }) }}
</div>
<div v-if="item.status === 'different'" class="mt-1">
{{
t("dataCompare.selectedInline", {
selected: selectedRows(item, "added") + selectedRows(item, "removed") + selectedRows(item, "modified"),
total: item.added + item.removed + item.modified,
})
}}
</div>
</template>
</td>
</tr>
</tbody>
</table>
<div class="rounded-lg border overflow-hidden">
<div class="max-h-64 overflow-auto">
<table class="w-full text-xs">
<thead class="bg-muted sticky top-0 z-10">
<tr>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.table") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("dataCompare.targetTable") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.status") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("diff.details") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in batchResults" :key="`${item.sourceTable}:${item.targetTable}`" class="border-t">
<td class="px-3 py-2 align-top font-mono">{{ item.sourceTable }}</td>
<td class="px-3 py-2 align-top font-mono text-muted-foreground">{{ item.targetTable }}</td>
<td class="px-3 py-2 align-top">
<span class="inline-flex rounded px-2 py-0.5 text-[11px]" :class="resultStatusClass(item.status)">
{{ resultStatusLabel(item.status) }}
</span>
</td>
<td class="px-3 py-2 align-top text-muted-foreground">
<div v-if="item.status === 'error'" class="text-destructive">{{ item.error }}</div>
<template v-else>
<div>
{{
t("dataCompare.summary", {
added: item.added,
removed: item.removed,
modified: item.modified,
})
}}
</div>
<div class="mt-1">
{{
t("dataCompare.rowCounts", {
source: item.sourceRowCount,
target: item.targetRowCount,
})
}}
</div>
<div class="mt-1">
{{ t("dataCompare.keyColumnsInline", { columns: item.keyColumns.join(", ") }) }}
</div>
<div v-if="item.status === 'different'" class="mt-1">
{{
t("dataCompare.selectedInline", {
selected: selectedRows(item, "added") + selectedRows(item, "removed") + selectedRows(item, "modified"),
total: item.added + item.removed + item.modified,
})
}}
</div>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="space-y-3">
<div v-for="item in batchResults.filter((entry) => entry.status === 'different')" :key="`details-${item.sourceTable}:${item.targetTable}`" class="rounded-lg border overflow-hidden">
<button type="button" class="flex w-full items-center gap-2 border-b bg-muted/30 px-3 py-2 text-left text-sm font-medium" @click="toggleTableExpanded(item)">
<ChevronDown v-if="item.expanded" class="h-4 w-4 shrink-0" />
<ChevronRight v-else class="h-4 w-4 shrink-0" />
<span class="font-mono">{{ item.sourceTable }}</span>
<span class="text-muted-foreground">→</span>
<span class="font-mono text-muted-foreground">{{ item.targetTable }}</span>
</button>
<div class="space-y-3">
<div v-for="item in batchResults.filter((entry) => entry.status === 'different')" :key="`details-${item.sourceTable}:${item.targetTable}`" class="rounded-lg border overflow-hidden">
<button type="button" class="flex w-full items-center gap-2 border-b bg-muted/30 px-3 py-2 text-left text-sm font-medium" @click="toggleTableExpanded(item)">
<ChevronDown v-if="item.expanded" class="h-4 w-4 shrink-0" />
<ChevronRight v-else class="h-4 w-4 shrink-0" />
<span class="font-mono">{{ item.sourceTable }}</span>
<span class="text-muted-foreground">→</span>
<span class="font-mono text-muted-foreground">{{ item.targetTable }}</span>
</button>
<div v-if="item.expanded" class="space-y-3 p-3">
<div v-for="kind in visibleKinds" :key="`${item.sourceTable}:${kind}`" class="rounded-lg border" v-show="hasDiffRows(item, kind)">
<div class="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-2 text-xs">
<span class="font-medium">{{ t(`diff.${kind}`) }}</span>
<span class="text-muted-foreground">{{ selectedRows(item, kind) }}/{{ item.diff[kind].length }}</span>
<span class="flex-1" />
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, true)">
{{ t("dataCompare.selectAllKind", { kind: t(`diff.${kind}`) }) }}
</Button>
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, false)">
{{ t("dataCompare.clearKind", { kind: t(`diff.${kind}`) }) }}
</Button>
<Button v-if="item.diff[kind].length > detailPreviewLimitNumber" size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="toggleShowAll(item, kind)">
{{ item.showAll[kind] ? t("dataCompare.showLessRows") : t("dataCompare.showAllRows", { count: item.diff[kind].length }) }}
</Button>
</div>
<div v-if="item.expanded" class="space-y-3 p-3">
<div v-for="kind in visibleKinds" :key="`${item.sourceTable}:${kind}`" class="rounded-lg border" v-show="hasDiffRows(item, kind)">
<div class="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-2 text-xs">
<span class="font-medium">{{ t(`diff.${kind}`) }}</span>
<span class="text-muted-foreground">{{ selectedRows(item, kind) }}/{{ item.diff[kind].length }}</span>
<span class="flex-1" />
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, true)">
{{ t("dataCompare.selectAllKind", { kind: t(`diff.${kind}`) }) }}
</Button>
<Button size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="setTableDiffSelection(item, kind, false)">
{{ t("dataCompare.clearKind", { kind: t(`diff.${kind}`) }) }}
</Button>
<Button v-if="item.diff[kind].length > detailPreviewLimitNumber" size="sm" variant="ghost" class="h-6 px-2 text-xs" @click="toggleShowAll(item, kind)">
{{ item.showAll[kind] ? t("dataCompare.showLessRows") : t("dataCompare.showAllRows", { count: item.diff[kind].length }) }}
</Button>
</div>
<div class="max-h-72 overflow-auto divide-y">
<button v-for="row in rowsForDisplay(item, kind)" :key="`${item.sourceTable}:${kind}:${row.key}`" type="button" class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40" @click="toggleRowSelection(row)">
<CheckSquare v-if="row.selected" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<div class="min-w-0 flex-1">
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
<div class="mt-1 text-muted-foreground break-words">
{{ kind === "modified" ? formatModifiedSummary(row as SelectableDataCompareModifiedRow) : formatRowValues((row as SelectableDataCompareRow).values) }}
<div class="max-h-72 overflow-auto divide-y">
<button v-for="row in rowsForDisplay(item, kind)" :key="`${item.sourceTable}:${kind}:${row.key}`" type="button" class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40" @click="toggleRowSelection(row)">
<CheckSquare v-if="row.selected" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<div class="min-w-0 flex-1">
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
<div class="mt-1 text-muted-foreground break-words">
{{ kind === "modified" ? formatModifiedSummary(row as SelectableDataCompareModifiedRow) : formatRowValues((row as SelectableDataCompareRow).values) }}
</div>
</div>
</div>
</button>
</div>
</button>
</div>
<div v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]" class="border-t px-3 py-2 text-xs text-muted-foreground">
{{ t("dataCompare.remainingRows", { count: remainingRows(item, kind) }) }}
<div v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]" class="border-t px-3 py-2 text-xs text-muted-foreground">
{{ t("dataCompare.remainingRows", { count: remainingRows(item, kind) }) }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="planningSync" class="text-sm text-muted-foreground">
{{ t("dataCompare.planningSync") }}
</div>
<div v-else-if="syncPlan.syncSql.trim()" class="space-y-1">
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea :value="syncPlan.syncSql" readonly class="w-full h-48 rounded-[6px] border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring" />
</div>
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
</div>
<div v-else class="text-sm text-muted-foreground">
{{ t("dataCompare.noSelectedDifferences") }}
</div>
</div>
<div v-if="syncErrors.length > 0" class="space-y-1">
<Label class="text-xs font-medium text-destructive">
{{ t("diff.syncSummary", { success: executeTotal - syncErrors.length, failed: syncErrors.length }) }}
</Label>
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
<span class="text-destructive">{{ err.error }}</span>
<span class="text-muted-foreground ml-1">— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span>
<div v-if="planningSync" class="text-sm text-muted-foreground">
{{ t("dataCompare.planningSync") }}
</div>
<div v-else-if="syncPlan.syncSql.trim()" class="space-y-1">
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea :value="syncPlan.syncSql" readonly class="w-full h-48 rounded-[6px] border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring" />
</div>
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
</div>
<div v-else class="text-sm text-muted-foreground">
{{ t("dataCompare.noSelectedDifferences") }}
</div>
</div>
</div>
</fieldset>
<div v-if="syncErrors.length > 0" class="space-y-1">
<Label class="text-xs font-medium text-destructive">
{{ t("diff.syncSummary", { success: executeTotal - syncErrors.length, failed: syncErrors.length }) }}
</Label>
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
<span class="text-destructive">{{ err.error }}</span>
<span class="text-muted-foreground ml-1">— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span>
</div>
</div>
</div>
</fieldset>
</div>
<DialogFooter v-if="!hasResults">
<Button variant="outline" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
@@ -486,3 +486,26 @@ describe("DataCompareDialog session restore", () => {
expect(session.syncPlan.syncSql).toBe("INSERT 2;");
});
});
describe("DataCompareDialog results layout", () => {
it("scrolls results in a plain container so the footer cannot overlap them", async () => {
// Chromium cannot scroll or clip a <fieldset> that owns `overflow-auto`
// (see issue #9839), so the scroll region must be a plain element and the
// fieldset may only stay in the tree as the disabled-state provider.
mountSessionDialog(completedSession());
await flushAsyncSetup();
const fieldset = document.querySelector("fieldset");
expect(fieldset).not.toBeNull();
expect(fieldset?.className).not.toContain("overflow-auto");
const scroller = fieldset?.parentElement;
expect(scroller?.tagName).toBe("DIV");
expect(scroller?.className).toContain("overflow-auto");
expect(scroller?.className).toContain("min-h-0");
const dialogFooter = document.querySelector('[data-slot="dialog-footer"]');
expect(dialogFooter).not.toBeNull();
expect(scroller?.contains(dialogFooter)).toBe(false);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB