feat(schema-diff): select fields and indexes for synchronization

This commit is contained in:
zhangyc authored and GitHub committed 2026-08-23 12:24:47 +08:00
1 parent d8bc5153d2
commit 726f86e24c
32 files changed
+1185 -413

No files matched your search

@@ -105,6 +105,15 @@ function handleUpdateSelectedTables(value: string[]) {
if (restrictTables.value) emit("update:selectedTables", [...value]);
}
function clearUnavailableTableSelection() {
sourceTableList.value = [];
targetTableList.value = [];
if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) return;
restrictTables.value = false;
localSelectedTables.value = [];
emit("update:selectedTables", undefined);
}
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
return side === "source" ? { connectionId: props.sourceConnectionId, database: props.sourceDatabase, schema: props.sourceSchema } : { connectionId: props.targetConnectionId, database: props.targetDatabase, schema: props.targetSchema };
}
@@ -277,13 +286,17 @@ watch(
selectedTables: props.selectedTables,
}),
(current, previous) => {
if (!isTableIdentityReady("source")) {
clearUnavailableTableSelection();
return;
}
if (!previous) return;
if (current.source.every((value, index) => value === previous.source[index])) return;
if (current.selectedTables !== previous.selectedTables) return;
if (current.selectedTables === undefined && !restrictTables.value) return;
restrictTables.value = false;
localSelectedTables.value = [];
emit("update:selectedTables", undefined);
clearUnavailableTableSelection();
},
{ immediate: true },
);
watch(
@@ -446,7 +459,15 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
{{ t("diff.tableSelectionUnrestricted") }}
</div>
<TableMultiSelect v-if="restrictTables" :key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`" :model-value="localSelectedTables" @update:model-value="handleUpdateSelectedTables" :tables="sourceTableList" :title="t('diff.sourceTables')" :empty-text="t('dataCompare.noTables')" />
<TableMultiSelect
v-if="restrictTables && canConfigureTableSelection"
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
:model-value="localSelectedTables"
@update:model-value="handleUpdateSelectedTables"
:tables="sourceTableList"
:title="t('diff.sourceTables')"
:empty-text="t('dataCompare.noTables')"
/>
</div>
<!-- Source Info -->
@@ -535,7 +556,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
</div>
<!-- Target Same-Name Match -->
<div v-if="restrictTables && localSelectedTables.length" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
@@ -8,6 +8,8 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
import { buildSchemaDiffHighlightSegments, type SchemaDiffHighlightSegment } from "@/lib/schema/schemaDiffHighlight";
import { findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
@@ -34,6 +36,7 @@ function toRgba(hex: string, alpha: number): string {
const props = defineProps<{
selectedObject: SchemaDiffObject | null;
focusedObject?: SchemaDiffObject | null;
deploySql: string;
deploySqlAll: string;
compatibilityWarnings?: CompatibilityWarning[];
@@ -57,6 +60,8 @@ const leftPaneRef = ref<HTMLDivElement>();
const rightPaneRef = ref<HTMLDivElement>();
const containerSize = ref({ width: 0, height: 0 });
const connectorKey = ref(0);
const focusedSourceLineNumber = ref<number | null>(null);
const focusedTargetLineNumber = ref<number | null>(null);
const rollbackDiffContainerRef = ref<HTMLDivElement>();
const rollbackLeftPaneRef = ref<HTMLDivElement>();
@@ -89,7 +94,7 @@ const { syncScroll: rollbackSyncScroll, measureHunks: rollbackMeasureHunks } = u
});
function collectModifySegments(diffHunks: ReturnType<typeof buildHunks>) {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
const map = new Map<string, { leftSegments: SchemaDiffHighlightSegment[]; rightSegments: SchemaDiffHighlightSegment[] }>();
for (const hunk of diffHunks) {
for (let i = 0; i < hunk.leftLines.length; i++) {
const left = hunk.leftLines[i];
@@ -99,13 +104,13 @@ function collectModifySegments(diffHunks: ReturnType<typeof buildHunks>) {
map.set(key, renderModifyLine(left, right));
} else if (left.type === "modify" && !left.isPadding && left.comparisonContent !== undefined) {
map.set(key, {
leftSegments: renderModifyContent(left.content, left.comparisonContent).sourceSegments,
leftSegments: buildSchemaDiffHighlightSegments(left.content, left.comparisonContent).sourceSegments,
rightSegments: [],
});
} else if (right.type === "modify" && !right.isPadding && right.comparisonContent !== undefined) {
map.set(key, {
leftSegments: [],
rightSegments: renderModifyContent(right.comparisonContent, right.content).targetSegments,
rightSegments: buildSchemaDiffHighlightSegments(right.comparisonContent, right.content).targetSegments,
});
}
}
@@ -177,14 +182,46 @@ function rollbackUpdateContainerSize() {
rollbackContainerSize.value = { width: rect.width, height: rect.height };
}
watch(
() => props.selectedObject?.id,
async () => {
await nextTick();
updateContainerSize();
requestMeasure();
},
);
watch([() => props.selectedObject?.id, () => props.focusedObject?.id, hunks], async () => {
if (props.focusedObject?.parentId) activeTab.value = "ddl";
await nextTick();
updateContainerSize();
requestMeasure();
locateFocusedObject();
});
function scrollPaneToLine(pane: HTMLDivElement | undefined, lineNumber: number | null) {
if (!pane || lineNumber === null) return;
const row = pane.querySelector<HTMLElement>(`[data-ddl-line-number="${lineNumber}"]`);
if (!row) return;
const paneRect = pane.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
pane.scrollTo({
top: Math.max(0, pane.scrollTop + rowRect.top - paneRect.top - pane.clientHeight / 2 + rowRect.height / 2),
behavior: "smooth",
});
}
function locateFocusedObject() {
const selectedObject = props.selectedObject;
const focusedObject = props.focusedObject;
if (!selectedObject || !focusedObject || focusedObject.id === selectedObject.id) {
focusedSourceLineNumber.value = null;
focusedTargetLineNumber.value = null;
return;
}
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
scrollPaneToLine(leftPaneRef.value, focusedSourceLineNumber.value);
scrollPaneToLine(rightPaneRef.value, focusedTargetLineNumber.value);
}
function focusedLineClass(line: DiffLine, side: "source" | "target"): string {
const focusedLineNumber = side === "source" ? focusedSourceLineNumber.value : focusedTargetLineNumber.value;
if (line.isPadding || focusedLineNumber === null) return "";
return line.lineNumber === focusedLineNumber ? "outline outline-1 -outline-offset-1 outline-primary/70" : "";
}
function updateContainerSize() {
const el = diffContainerRef.value;
@@ -225,86 +262,11 @@ function lineTextClass(line: DiffLine): string {
return "";
}
function computeCharDiffs(source: string, target: string): { source: string; target: string }[] {
const result: { source: string; target: string }[] = [];
let sIdx = 0;
let tIdx = 0;
while (sIdx < source.length || tIdx < target.length) {
if (sIdx >= source.length) {
result.push({ source: "", target: target.substring(tIdx) });
break;
}
if (tIdx >= target.length) {
result.push({ source: source.substring(sIdx), target: "" });
break;
}
if (source[sIdx] === target[tIdx]) {
let matchLen = 0;
while (sIdx + matchLen < source.length && tIdx + matchLen < target.length && source[sIdx + matchLen] === target[tIdx + matchLen]) {
matchLen++;
}
result.push({
source: source.substring(sIdx, sIdx + matchLen),
target: target.substring(tIdx, tIdx + matchLen),
});
sIdx += matchLen;
tIdx += matchLen;
} else {
let sMatch = -1;
let tMatch = -1;
for (let i = 0; i < Math.min(10, source.length - sIdx, target.length - tIdx); i++) {
if (source[sIdx + i] === target[tIdx]) {
sMatch = i;
tMatch = 0;
break;
}
if (source[sIdx] === target[tIdx + i]) {
sMatch = 0;
tMatch = i;
break;
}
}
if (sMatch === -1) {
sMatch = Math.min(1, source.length - sIdx);
tMatch = Math.min(1, target.length - tIdx);
}
result.push({
source: source.substring(sIdx, sIdx + (sMatch > 0 ? sMatch : 1)),
target: target.substring(tIdx, tIdx + (tMatch > 0 ? tMatch : 1)),
});
sIdx += sMatch > 0 ? sMatch : 1;
tIdx += tMatch > 0 ? tMatch : 1;
}
}
return result;
}
function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegments: Segment[]; rightSegments: Segment[] } {
const { sourceSegments, targetSegments } = renderModifyContent(leftLine.content, rightLine.content);
function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegments: SchemaDiffHighlightSegment[]; rightSegments: SchemaDiffHighlightSegment[] } {
const { sourceSegments, targetSegments } = buildSchemaDiffHighlightSegments(leftLine.content, rightLine.content);
return { leftSegments: sourceSegments, rightSegments: targetSegments };
}
function renderModifyContent(source: string, target: string): { sourceSegments: Segment[]; targetSegments: Segment[] } {
const charDiffs = computeCharDiffs(source, target);
const leftSegments: Segment[] = [];
const rightSegments: Segment[] = [];
for (const cd of charDiffs) {
if (cd.source === cd.target) {
leftSegments.push({ text: cd.source, changed: false });
rightSegments.push({ text: cd.target, changed: false });
} else {
if (cd.source) leftSegments.push({ text: cd.source, changed: true });
if (cd.target) rightSegments.push({ text: cd.target, changed: true });
}
}
return { sourceSegments: leftSegments, targetSegments: rightSegments };
}
interface Segment {
text: string;
changed: boolean;
}
function copyDeploySql() {
copyToClipboard(props.deploySql);
toast(t("diff.copied"), 2000);
@@ -386,19 +348,11 @@ function copyDeploySqlAll() {
<!-- Source DDL -->
<Pane min-size="20">
<div ref="leftPaneRef" class="h-full overflow-y-auto border-r" @scroll="handleScroll('left')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("diff.sourceDdl") }}
</div>
<div v-for="hunk in hunks" :key="`left-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.leftLines"
:key="`l-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<div v-for="(line, idx) in hunk.leftLines" :key="`l-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :class="focusedLineClass(line, 'source')" :data-ddl-line-number="line.lineNumber ?? undefined" :style="{ backgroundColor: lineBackground(line) }">
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
@@ -418,19 +372,11 @@ function copyDeploySqlAll() {
<!-- Target DDL -->
<Pane min-size="20">
<div ref="rightPaneRef" class="h-full overflow-y-auto" @scroll="handleScroll('right')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("diff.targetDdl") }}
</div>
<div v-for="hunk in hunks" :key="`right-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.rightLines"
:key="`r-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<div v-for="(line, idx) in hunk.rightLines" :key="`r-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :class="focusedLineClass(line, 'target')" :data-ddl-line-number="line.lineNumber ?? undefined" :style="{ backgroundColor: lineBackground(line) }">
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
@@ -462,19 +408,11 @@ function copyDeploySqlAll() {
<Splitpanes class="h-full" @resized="rollbackOnSplitpanesResized">
<Pane min-size="20">
<div ref="rollbackLeftPaneRef" class="h-full overflow-y-auto border-r" @scroll="rollbackHandleScroll('left')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("rollbackComparison.forwardSql") }}
</div>
<div v-for="hunk in rollbackHunks" :key="`left-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.leftLines"
:key="`l-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<div v-for="(line, idx) in hunk.leftLines" :key="`l-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :style="{ backgroundColor: lineBackground(line) }">
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
@@ -492,19 +430,11 @@ function copyDeploySqlAll() {
</Pane>
<Pane min-size="20">
<div ref="rollbackRightPaneRef" class="h-full overflow-y-auto" @scroll="rollbackHandleScroll('right')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("rollbackComparison.rollbackSql") }}
</div>
<div v-for="hunk in rollbackHunks" :key="`right-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.rightLines"
:key="`r-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<div v-for="(line, idx) in hunk.rightLines" :key="`r-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :style="{ backgroundColor: lineBackground(line) }">
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
@@ -9,7 +9,20 @@ import { useTheme } from "@/composables/useTheme";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { Splitpanes, Pane } from "splitpanes";
import { schemaDiffReviewAlert, selectedSchemaDiffObjects, summarizeSchemaDiffOperations, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type CompatibilityWarning, type RenameCandidate, type MissingRollbackObject, type RollbackCompleteness } from "@/lib/schema/schemaDiff";
import {
schemaDiffObjectSelectionState,
schemaDiffReviewAlert,
schemaDiffSelectionTargets,
selectedSchemaDiffObjects,
summarizeSchemaDiffOperations,
type SchemaDiffObject,
type DiffOperationType,
type DiffObjectKind,
type CompatibilityWarning,
type RenameCandidate,
type MissingRollbackObject,
type RollbackCompleteness,
} from "@/lib/schema/schemaDiff";
import ImpactReportPanel from "@/components/diff/ImpactReportPanel.vue";
import type { ImpactReport } from "@/types/governance";
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
@@ -54,13 +67,13 @@ const objectPositions = computed(() => {
const positions = new Map<string, { from: number; to: number }>();
const sql = props.deploySql;
for (const obj of topLevelObjects.value) {
const patterns = [`-- Create ${obj.objectKind}: ${obj.name}`, `-- Modify ${obj.objectKind}: ${obj.name}`, `-- Drop ${obj.objectKind}: ${obj.name}`];
const patterns = [`-- Create ${obj.objectKind}: ${obj.name}`, `-- Alter ${obj.objectKind}: ${obj.name}`, `-- Modify ${obj.objectKind}: ${obj.name}`, `-- Drop ${obj.objectKind}: ${obj.name}`];
for (const pattern of patterns) {
const index = sql.indexOf(pattern);
if (index !== -1) {
let endPos = sql.length;
const remaining = sql.slice(index + pattern.length);
const nextMatch = remaining.match(/--\s*(Create|Modify|Drop)\s+\w+:/);
const nextMatch = remaining.match(/--\s*(Create|Alter|Modify|Drop)\s+\w+:/);
if (nextMatch && nextMatch.index !== undefined) {
endPos = index + pattern.length + nextMatch.index;
}
@@ -90,8 +103,8 @@ const topLevelObjects = computed(() => {
const operationOrder: Record<DiffOperationType, number> = { create: 0, modify: 1, delete: 2, none: 3 };
return props.selectedObjects
.filter((o) => {
const isTopLevel = !o.id.startsWith("col-") && !o.id.startsWith("idx-") && !o.id.startsWith("fk-") && !o.id.startsWith("trg-");
return o.selected && o.operationType !== "none" && isTopLevel;
const state = schemaDiffObjectSelectionState(o);
return o.operationType !== "none" && (state.checked || state.indeterminate);
})
.sort((a, b) => operationOrder[a.operationType] - operationOrder[b.operationType]);
});
@@ -338,6 +351,7 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5 shrink-0" :class="operationColors[obj.operationType]" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="truncate">{{ obj.name }}</span>
<span v-if="obj.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(obj).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(obj).length }}</span>
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">
{{ getOperationLabel(obj.operationType) }}
</span>
@@ -22,24 +22,26 @@ import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurr
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
import type { ObjectSourceKind, TableInfo } from "@/types/database";
import type { DatabaseType, ObjectSourceKind, TableInfo } from "@/types/database";
import {
buildDeploySqlForObjects,
convertToSchemaDiffObjects,
detectDestructiveSchemaDiffStatements,
groupDiffObjects,
injectColumnRenameSql,
schemaDiffDeployTargetSchema,
schemaDiffSelectionOwnerId,
findSchemaDiffObject,
flattenSchemaDiffObjects,
schemaDiffSelectionTargets,
selectSchemaDiffInput,
selectedSchemaDiffObjects,
setSchemaDiffObjectSelected,
setSchemaDiffObjectSelectedWithDependencies,
summarizeSchemaDiffOperations,
databaseTypeToDialectKind,
normalizeDialectKind,
type OperationGroup,
type SchemaDiffObject,
type DiffOperationType,
type DiffObjectKind,
type SchemaDiffPreparation,
type MissingRollbackObject,
type RollbackCompleteness,
@@ -122,6 +124,7 @@ const renameCandidates = ref<RenameCandidate[]>([]);
const compatibilityWarnings = ref<CompatibilityWarning[]>([]);
const permissionDiffs = ref<PermissionDiff[]>([]);
const dependencyGraph = ref<DependencyGraph | null>(null);
let deploySqlGeneration = 0;
// Rename candidates panel
const showRenamePanel = ref(true);
@@ -250,25 +253,54 @@ const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfi
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
const selectedObject = computed(() => {
const object = selectedTreeObject.value;
if (!object) return null;
return object.parentId ? (findSchemaDiffObject(diffObjects.value, object.parentId) ?? object) : object;
});
const selectedTreeObject = computed(() => {
if (!selectedObjectId.value) return null;
for (const group of diffGroups.value) {
for (const typeGroup of group.typeGroups) {
const obj = typeGroup.objects.find((o) => o.id === selectedObjectId.value);
if (obj) return obj;
const object = flattenSchemaDiffObjects(typeGroup.objects).find((candidate) => candidate.id === selectedObjectId.value);
if (object) return object;
}
}
return null;
});
const canDeploy = computed(() => {
return diffObjects.value.some((o) => o.selected && o.operationType !== "none");
return selectedSchemaDiffObjects(diffObjects.value).length > 0;
});
function resetComparisonResultState() {
deploySqlGeneration++;
step.value = "config";
diffObjects.value = [];
diffGroups.value = [];
selectedObjectId.value = null;
deploySql.value = "";
deploySqlAll.value = "";
lastDiffResult.value = null;
rollbackSql.value = "";
rollbackCompleteness.value = "complete";
missingRollbackObjects.value = [];
renameCandidates.value = [];
compatibilityWarnings.value = [];
permissionDiffs.value = [];
dependencyGraph.value = null;
deploySqlMode.value = "forward";
showConfirmDialog.value = false;
showResultDialog.value = false;
deployResult.value = null;
}
// Watch for prefilled values
watch(
() => open.value,
(isOpen) => {
if (isOpen) {
resetComparisonResultState();
ensureDefaultConfig();
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
@@ -308,7 +340,7 @@ watch(
},
);
function getDbType(): string {
function getDbType(): DatabaseType {
const targetConfig = store.getConfig(targetConnectionId.value);
return targetConfig?.db_type || "postgres";
}
@@ -528,7 +560,7 @@ async function handleCompare() {
}
}
deploySqlMode.value = "forward";
regenerateDeploySql();
await regenerateDeploySql();
step.value = "result";
} catch (e: any) {
@@ -543,39 +575,29 @@ function handleToggleGroup(operationType: DiffOperationType) {
diffGroups.value = diffGroups.value.map((g) => (g.operationType === operationType ? { ...g, expanded: !g.expanded } : g));
}
function handleToggleTypeGroup(operationType: DiffOperationType, kind: DiffObjectKind) {
diffGroups.value = diffGroups.value.map((g) => {
if (g.operationType !== operationType) return g;
return {
...g,
typeGroups: g.typeGroups.map((tg) => (tg.kind === kind ? { ...tg, expanded: !tg.expanded } : tg)),
};
});
}
function handleToggleGroupSelection(operationType: DiffOperationType, selected: boolean) {
const group = diffGroups.value.find((candidate) => candidate.operationType === operationType);
for (const object of group?.typeGroups.flatMap((typeGroup) => typeGroup.objects) ?? []) {
setSchemaDiffObjectSelected(diffObjects.value, schemaDiffSelectionOwnerId(object), selected);
for (const target of schemaDiffSelectionTargets(object)) {
updateObjectSelection(target.id, selected);
}
}
rebuildDiffGroups();
regenerateDeploySql();
void regenerateDeploySql();
}
function handleToggleTypeSelection(operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean) {
const typeGroup = diffGroups.value.find((group) => group.operationType === operationType)?.typeGroups.find((candidate) => candidate.kind === kind);
for (const object of typeGroup?.objects ?? []) {
setSchemaDiffObjectSelected(diffObjects.value, schemaDiffSelectionOwnerId(object), selected);
function handleToggleObjectSelection(object: SchemaDiffObject, selected: boolean) {
let changed = false;
for (const target of schemaDiffSelectionTargets(object)) {
changed = updateObjectSelection(target.id, selected) || changed;
}
if (!changed) return;
rebuildDiffGroups();
regenerateDeploySql();
void regenerateDeploySql();
}
function handleToggleObjectSelection(objectId: string, selected: boolean) {
const reviewObject = diffGroups.value.flatMap((group) => group.typeGroups.flatMap((typeGroup) => typeGroup.objects)).find((object) => object.id === objectId);
if (!setSchemaDiffObjectSelected(diffObjects.value, reviewObject ? schemaDiffSelectionOwnerId(reviewObject) : objectId, selected)) return;
rebuildDiffGroups();
regenerateDeploySql();
function updateObjectSelection(objectId: string, selected: boolean): boolean {
return lastDiffResult.value ? setSchemaDiffObjectSelectedWithDependencies(diffObjects.value, lastDiffResult.value, objectId, selected) : setSchemaDiffObjectSelected(diffObjects.value, objectId, selected);
}
function rebuildDiffGroups() {
@@ -591,8 +613,43 @@ function rebuildDiffGroups() {
}));
}
function regenerateDeploySql() {
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
async function regenerateDeploySql() {
const result = lastDiffResult.value;
if (!result) {
deploySql.value = "-- No objects selected";
rollbackSql.value = "";
return;
}
const generation = ++deploySqlGeneration;
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
const input = selectSchemaDiffInput(result, diffObjects.value);
let plan;
try {
plan = await api.generateSchemaSyncPlan(input, {
databaseType: getDbType(),
targetSchema: schemaDiffDeployTargetSchema(getDbType(), targetDatabase.value, targetSchema.value),
cascadeDelete: options.cascadeDelete,
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceDbType.value ? databaseTypeToDialectKind(sourceDbType.value) : undefined,
fieldMappings: options.fieldMappings,
enableRollback: options.enableRollback,
});
} catch (error: any) {
if (generation === deploySqlGeneration) toast(error?.message || String(error), 5000);
return;
}
if (generation !== deploySqlGeneration) return;
let forwardSql = plan.syncSql || "-- No objects selected";
let nextRollbackSql = plan.rollbackSyncSql ?? "";
rollbackCompleteness.value = plan.rollbackCompleteness ?? "complete";
missingRollbackObjects.value = plan.missingRollbackObjects ?? [];
if (options.detectRenames && options.renameThreshold) {
forwardSql = injectColumnRenameSql(forwardSql, input.diffs, options.renameThreshold);
if (nextRollbackSql) nextRollbackSql = injectColumnRenameSql(nextRollbackSql, input.diffs, options.renameThreshold, true);
}
rollbackSql.value = nextRollbackSql;
deploySql.value = deploySqlMode.value === "rollback" && nextRollbackSql ? nextRollbackSql : forwardSql;
}
function switchDeploySqlMode(mode: "forward" | "rollback") {
@@ -604,7 +661,7 @@ function switchDeploySqlMode(mode: "forward" | "rollback") {
if (mode === "rollback" && rollbackSql.value) {
deploySql.value = rollbackSql.value;
} else {
regenerateDeploySql();
void regenerateDeploySql();
}
}
@@ -643,7 +700,8 @@ function applyRename(rc: RenameCandidate) {
}
}
if (found) {
regenerateDeploySql();
rebuildDiffGroups();
void regenerateDeploySql();
toast(t("diff.renameApplied"), 2000);
}
}
@@ -659,11 +717,12 @@ function ignoreRename(index: number) {
}
}
renameCandidates.value.splice(index, 1);
regenerateDeploySql();
rebuildDiffGroups();
void regenerateDeploySql();
}
async function handleExecuteScript() {
if (!deploySql.value || deploySql.value.startsWith("-- ")) {
if (!deploySql.value.trim() || deploySql.value.trim() === "-- No objects selected") {
toast(t("diff.noObjectsSelected"), 3000);
return;
}
@@ -706,8 +765,9 @@ function showDeployTxResult(txLog: any) {
deployResult.value = buildDeployTxResult(txLog, t);
showResultDialog.value = true;
}
async function handleSelectObject(obj: SchemaDiffObject) {
selectedObjectId.value = obj.id;
async function handleSelectObject(reviewObject: SchemaDiffObject) {
selectedObjectId.value = reviewObject.id;
const obj = reviewObject.parentId ? (findSchemaDiffObject(diffObjects.value, reviewObject.parentId) ?? reviewObject) : reviewObject;
// Dynamically fetch DDL for objects that don't have pre-generated DDL
// (views need runtime retrieval; functions should already have definition)
@@ -806,7 +866,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
}
function handleDeployReview() {
const selectedObjects = diffObjects.value.filter((o) => o.selected && o.operationType !== "none");
const selectedObjects = selectedSchemaDiffObjects(diffObjects.value);
if (selectedObjects.length === 0) {
toast(t("diff.noObjectsSelected"), 3000);
return;
@@ -843,6 +903,13 @@ const deployStats = computed(() => {
};
});
const selectedCompatibilityWarnings = computed(() => {
if (!lastDiffResult.value) return [];
const input = selectSchemaDiffInput(lastDiffResult.value, diffObjects.value);
const selectedColumns = new Set(input.diffs.flatMap((diff) => (diff.columns ?? []).map((column) => `${diff.name}\u0000${column.name}`)));
return compatibilityWarnings.value.filter((warning) => selectedColumns.has(`${warning.table}\u0000${warning.column}`));
});
const targetConnectionInfo = computed(() => {
const config = store.getConfig(targetConnectionId.value);
if (!config) return null;
@@ -959,24 +1026,16 @@ const targetConnectionInfo = computed(() => {
<Splitpanes horizontal class="flex-1 min-h-0" @resized="handleSplitpanesResized">
<Pane :size="splitpanesSize" min-size="20">
<div class="h-full overflow-auto">
<SchemaDiffObjectTree
:groups="diffGroups"
:selected-object-id="selectedObject?.id ?? null"
@toggle-group="handleToggleGroup"
@toggle-type-group="handleToggleTypeGroup"
@toggle-group-selection="handleToggleGroupSelection"
@toggle-type-selection="handleToggleTypeSelection"
@toggle-object-selection="handleToggleObjectSelection"
@select-object="handleSelectObject"
/>
<SchemaDiffObjectTree :groups="diffGroups" :selected-object-id="selectedObjectId" @toggle-group="handleToggleGroup" @toggle-group-selection="handleToggleGroupSelection" @toggle-object-selection="handleToggleObjectSelection" @select-object="handleSelectObject" />
</div>
</Pane>
<Pane :size="100 - splitpanesSize" min-size="20">
<SchemaDiffDdlPanel
:selected-object="selectedObject"
:focused-object="selectedTreeObject"
:deploy-sql="deploySql"
:deploy-sql-all="deploySqlAll"
:compatibility-warnings="compatibilityWarnings"
:compatibility-warnings="selectedCompatibilityWarnings"
:rollback-sql="rollbackSql"
:deploy-sql-mode="deploySqlMode"
:dependency-graph="dependencyGraph"
@@ -1002,7 +1061,7 @@ const targetConnectionInfo = computed(() => {
:executing="executing"
:rollback-sql="rollbackSql"
:deploy-sql-mode="deploySqlMode"
:compatibility-warnings="compatibilityWarnings"
:compatibility-warnings="selectedCompatibilityWarnings"
:rename-candidates="renameCandidates"
:rollback-completeness="rollbackCompleteness"
:missing-rollback-objects="missingRollbackObjects"
@@ -1,25 +1,11 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
import { schemaDiffObjectSelectionState, schemaDiffSelectionTargets, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type OperationGroup } from "@/lib/schema/schemaDiff";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, SlidersHorizontal, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
const { t } = useI18n();
interface ObjectTypeGroup {
kind: DiffObjectKind;
label: string;
objects: SchemaDiffObject[];
expanded: boolean;
}
interface OperationGroup {
operationType: DiffOperationType;
label: string;
count: number;
selectedCount: number;
expanded: boolean;
typeGroups: ObjectTypeGroup[];
}
const expandedObjectIds = ref(new Set<string>());
const props = defineProps<{
groups: OperationGroup[];
@@ -28,10 +14,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "toggleGroup", operationType: DiffOperationType): void;
(e: "toggleTypeGroup", operationType: DiffOperationType, kind: DiffObjectKind): void;
(e: "toggleGroupSelection", operationType: DiffOperationType, selected: boolean): void;
(e: "toggleTypeSelection", operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean): void;
(e: "toggleObjectSelection", objectId: string, selected: boolean): void;
(e: "toggleObjectSelection", object: SchemaDiffObject, selected: boolean): void;
(e: "selectObject", object: SchemaDiffObject): void;
}>();
@@ -78,6 +62,8 @@ function getObjectIcon(kind: DiffObjectKind) {
return Link2;
case "trigger":
return Zap;
case "tableOption":
return SlidersHorizontal;
default:
return Table;
}
@@ -105,144 +91,137 @@ function getObjectIconColor(kind: DiffObjectKind): string {
return "text-lime-500";
case "trigger":
return "text-rose-500";
case "tableOption":
return "text-muted-foreground";
default:
return "text-muted-foreground";
}
}
function getObjectTypeLabel(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "diff.objectKindLabel.table";
case "view":
return "diff.objectKindLabel.view";
case "function":
return "diff.objectKindLabel.function";
case "sequence":
return "diff.objectKindLabel.sequence";
case "rule":
return "diff.objectKindLabel.rule";
case "owner":
return "diff.objectKindLabel.owner";
case "column":
return "diff.objectKindLabel.column";
case "index":
return "diff.objectKindLabel.index";
case "foreignKey":
return "diff.objectKindLabel.foreignKey";
case "trigger":
return "diff.objectKindLabel.trigger";
default:
return kind;
}
function groupObjects(group: OperationGroup): SchemaDiffObject[] {
return group.typeGroups.flatMap((typeGroup) => typeGroup.objects);
}
function isGroupFullySelected(group: OperationGroup): boolean {
return group.count > 0 && group.selectedCount === group.count;
function groupSelectionState(group: OperationGroup): { checked: boolean; indeterminate: boolean } {
const targets = groupObjects(group).flatMap(schemaDiffSelectionTargets);
const selectedCount = targets.filter((target) => target.selected).length;
return {
checked: targets.length > 0 && selectedCount === targets.length,
indeterminate: selectedCount > 0 && selectedCount < targets.length,
};
}
function isGroupPartiallySelected(group: OperationGroup): boolean {
return group.selectedCount > 0 && group.selectedCount < group.count;
function toggleObjectExpanded(objectId: string) {
const next = new Set(expandedObjectIds.value);
if (next.has(objectId)) next.delete(objectId);
else next.add(objectId);
expandedObjectIds.value = next;
}
function isTypeGroupFullySelected(typeGroup: ObjectTypeGroup): boolean {
return typeGroup.objects.length > 0 && typeGroup.objects.every((o) => o.selected);
}
function isTypeGroupPartiallySelected(typeGroup: ObjectTypeGroup): boolean {
const selectedCount = typeGroup.objects.filter((o) => o.selected).length;
return selectedCount > 0 && selectedCount < typeGroup.objects.length;
function isObjectExpanded(objectId: string): boolean {
return expandedObjectIds.value.has(objectId);
}
function onGroupCheckboxChange(group: OperationGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleGroupSelection", group.operationType, checked);
emit("toggleGroupSelection", group.operationType, (event.target as HTMLInputElement).checked);
}
function onTypeCheckboxChange(group: OperationGroup, typeGroup: ObjectTypeGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleTypeSelection", group.operationType, typeGroup.kind, checked);
function onObjectCheckboxChange(object: SchemaDiffObject, event: Event) {
emit("toggleObjectSelection", object, (event.target as HTMLInputElement).checked);
}
function onObjectCheckboxChange(obj: SchemaDiffObject, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleObjectSelection", obj.id, checked);
function displayObjectName(object: SchemaDiffObject): string {
if (object.objectKind === "tableOption") return t("diff.objectKindLabel.tableOption");
if (object.objectKind === "function" && object.arguments) return `${object.name}(${object.arguments})`;
return object.name;
}
function formatObjectName(obj: SchemaDiffObject): string {
const name = obj.parentName ? `${obj.parentName}.${obj.name}` : obj.name;
if (obj.objectKind === "function" && obj.arguments) {
return `${name}(${obj.arguments})`;
}
return name;
function sourceObjectName(object: SchemaDiffObject): string {
return object.sourceName ?? displayObjectName(object);
}
function targetObjectName(object: SchemaDiffObject): string {
return object.targetName ?? displayObjectName(object);
}
</script>
<template>
<div class="space-y-1">
<!-- Header -->
<div class="grid grid-cols-[1fr_60px_1fr] gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground border-b">
<div class="text-center">{{ t("diff.sourceObject") }}</div>
<div class="text-center">{{ t("diff.operation") }}</div>
<div class="text-center">{{ t("diff.targetObject") }}</div>
</div>
<!-- Operation Groups -->
<div v-for="group in groups" :key="group.operationType" class="border rounded-md overflow-hidden">
<!-- Operation Group Header -->
<div v-for="group in props.groups" :key="group.operationType" class="border rounded-md overflow-hidden">
<button class="flex items-center gap-2 w-full px-3 py-2 text-sm font-medium transition-colors" :class="operationBgColors[group.operationType]" @click="$emit('toggleGroup', group.operationType)">
<ChevronDown v-if="group.expanded" class="w-4 h-4 shrink-0" />
<ChevronRight v-else class="w-4 h-4 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="isGroupFullySelected(group)" :indeterminate="isGroupPartiallySelected(group)" @click.stop @change="onGroupCheckboxChange(group, $event)" />
<input type="checkbox" class="accent-primary shrink-0" :checked="groupSelectionState(group).checked" :indeterminate="groupSelectionState(group).indeterminate" :disabled="group.count === 0" @click.stop @change="onGroupCheckboxChange(group, $event)" />
<component :is="operationIcons[group.operationType]" class="w-4 h-4 shrink-0" :class="operationColors[group.operationType]" />
<span :class="operationColors[group.operationType]">{{ t(group.label) }}</span>
<span class="text-xs text-muted-foreground ml-1"> ({{ t("diff.selectedCount", { selected: group.selectedCount, total: group.count }) }}) </span>
<span class="text-xs text-muted-foreground ml-1">({{ t("diff.selectedCount", { selected: group.selectedCount, total: group.count }) }})</span>
</button>
<!-- Type Groups -->
<div v-if="group.expanded" class="divide-y divide-border/30">
<div v-for="typeGroup in group.typeGroups" :key="typeGroup.kind" class="border-l-2 border-l-border/50 ml-2">
<!-- Type Group Header -->
<button class="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium hover:bg-accent/20 transition-colors" @click="$emit('toggleTypeGroup', group.operationType, typeGroup.kind)">
<ChevronDown v-if="typeGroup.expanded" class="w-3.5 h-3.5 shrink-0" />
<ChevronRight v-else class="w-3.5 h-3.5 shrink-0" />
<div v-for="object in groupObjects(group)" :key="object.id">
<div class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1.5 items-center hover:bg-accent/30 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === object.id }" @click="$emit('selectObject', object)">
<div v-if="object.operationType !== 'delete'" class="flex items-center gap-2 min-w-0 pl-6">
<button v-if="object.children?.length" type="button" class="shrink-0" @click.stop="toggleObjectExpanded(object.id)">
<ChevronDown v-if="isObjectExpanded(object.id)" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
<span v-else class="w-3.5 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="schemaDiffObjectSelectionState(object).checked" :indeterminate="schemaDiffObjectSelectionState(object).indeterminate" @click.stop @change="onObjectCheckboxChange(object, $event)" />
<component :is="getObjectIcon(object.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(object.objectKind)" />
<span class="text-xs truncate" :class="object.operationType === 'create' ? 'text-green-500' : ''">{{ sourceObjectName(object) }}</span>
<span v-if="object.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(object).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(object).length }}</span>
</div>
<div v-else />
<input type="checkbox" class="accent-primary shrink-0" :checked="isTypeGroupFullySelected(typeGroup)" :indeterminate="isTypeGroupPartiallySelected(typeGroup)" @click.stop @change="onTypeCheckboxChange(group, typeGroup, $event)" />
<div class="flex justify-center">
<component :is="operationIcons[object.operationType]" class="w-3.5 h-3.5" :class="operationColors[object.operationType]" />
</div>
<component :is="getObjectIcon(typeGroup.kind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(typeGroup.kind)" />
<span>{{ t(getObjectTypeLabel(typeGroup.kind)) }}</span>
<span class="text-xs text-muted-foreground">({{ typeGroup.objects.length }})</span>
</button>
<div v-if="object.operationType !== 'create'" class="flex items-center gap-2 min-w-0 pl-6">
<button v-if="object.operationType === 'delete' && object.children?.length" type="button" class="shrink-0" @click.stop="toggleObjectExpanded(object.id)">
<ChevronDown v-if="isObjectExpanded(object.id)" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
<span v-else-if="object.operationType === 'delete'" class="w-3.5 shrink-0" />
<input
v-if="object.operationType === 'delete'"
type="checkbox"
class="accent-primary shrink-0"
:checked="schemaDiffObjectSelectionState(object).checked"
:indeterminate="schemaDiffObjectSelectionState(object).indeterminate"
@click.stop
@change="onObjectCheckboxChange(object, $event)"
/>
<component :is="getObjectIcon(object.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(object.objectKind)" />
<span class="text-xs truncate" :class="object.operationType === 'delete' ? 'text-red-500 line-through' : ''">{{ targetObjectName(object) }}</span>
<span v-if="object.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(object).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(object).length }}</span>
</div>
<div v-else />
</div>
<!-- Objects -->
<div v-if="typeGroup.expanded" class="divide-y divide-border/20">
<div v-for="obj in typeGroup.objects" :key="obj.id" class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1 items-center hover:bg-accent/30 cursor-pointer ml-8" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="$emit('selectObject', obj)">
<!-- Source (hide for delete objects) -->
<div v-if="obj.operationType !== 'delete'" class="flex items-center gap-2 min-w-0">
<input type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'create' ? 'text-green-500' : ''">
{{ obj.sourceName ? (obj.parentName ? `${obj.parentName}.${obj.sourceName}` : obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
</span>
<div v-if="object.children?.length && isObjectExpanded(object.id)" class="border-t border-border/20 bg-muted/10">
<div v-for="child in object.children" :key="child.id" class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1 items-center hover:bg-accent/30 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === child.id }" @click="$emit('selectObject', child)">
<div v-if="child.operationType !== 'delete'" class="flex items-center gap-2 min-w-0 pl-16">
<input type="checkbox" class="accent-primary shrink-0" :checked="child.selected" @click.stop @change="onObjectCheckboxChange(child, $event)" />
<component :is="getObjectIcon(child.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(child.objectKind)" />
<span class="text-xs truncate">{{ sourceObjectName(child) }}</span>
</div>
<div v-else></div>
<!-- Operation -->
<div v-else />
<div class="flex justify-center">
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5" :class="operationColors[obj.operationType]" />
<component :is="operationIcons[child.operationType]" class="w-3.5 h-3.5" :class="operationColors[child.operationType]" />
</div>
<!-- Target (hide for create objects) -->
<div v-if="obj.operationType !== 'create'" class="flex items-center gap-2 min-w-0">
<input v-if="obj.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'delete' ? 'text-red-500 line-through' : ''">
{{ obj.targetName ? (obj.parentName ? `${obj.parentName}.${obj.targetName}` : obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
</span>
<div v-if="child.operationType !== 'create'" class="flex items-center gap-2 min-w-0 pl-16">
<input v-if="child.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="child.selected" @click.stop @change="onObjectCheckboxChange(child, $event)" />
<component :is="getObjectIcon(child.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(child.objectKind)" />
<span class="text-xs truncate" :class="child.operationType === 'delete' ? 'text-red-500 line-through' : ''">{{ targetObjectName(child) }}</span>
</div>
<div v-else></div>
<div v-else />
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const panelSource = readFileSync(new URL("../SchemaDiffDdlPanel.vue", import.meta.url), "utf8");
describe("SchemaDiffDdlPanel diff highlighting", () => {
it("keeps modified rows height-stable and sticky DDL headers opaque", () => {
expect(panelSource).not.toContain("border-yellow-500/40");
expect(panelSource).not.toContain("sticky top-0 bg-muted/50");
expect(panelSource.match(/sticky top-0 bg-background/g)).toHaveLength(4);
});
it("never applies the focused-row outline to empty padding lines", () => {
expect(panelSource).toContain('if (line.isPadding || focusedLineNumber === null) return "";');
});
});
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../SchemaDiffDialog.vue", import.meta.url), "utf8");
const configStepSource = readFileSync(new URL("../SchemaDiffConfigStep.vue", import.meta.url), "utf8");
describe("SchemaDiffDialog fullscreen layout", () => {
it("fits the dialog to its portal instead of the viewport width", () => {
@@ -39,4 +40,18 @@ describe("SchemaDiffDialog fullscreen layout", () => {
// become reachable via scrolling.
expect(dialogSource).toMatch(/<SchemaDiffConfigStep\n[\s\S]*?class="shrink-0"/);
});
it("clears stale result state whenever a new dialog session opens", () => {
expect(dialogSource).toContain("function resetComparisonResultState() {");
expect(dialogSource).toMatch(/if \(isOpen\) \{\s+resetComparisonResultState\(\);/);
expect(dialogSource).toContain("diffObjects.value = [];");
expect(dialogSource).toContain("lastDiffResult.value = null;");
});
it("does not restore persisted table matches before source and target identities are ready", () => {
expect(configStepSource).toContain('if (!isTableIdentityReady("source")) {');
expect(configStepSource).toContain("clearUnavailableTableSelection();");
expect(configStepSource).toContain('v-if="restrictTables && canConfigureTableSelection"');
expect(configStepSource).toContain("restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')");
});
});
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { convertToSchemaDiffObjects, groupDiffObjects, type SchemaDiffObject } from "@/lib/schema/schemaDiff";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, params?: { selected: number; total: number }) => (params ? `${key}:${params.selected}/${params.total}` : key) }) }));
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("SchemaDiffObjectTree", () => {
it("renders operation -> table -> field/index selection without a database root", async () => {
const groups = groupDiffObjects(
convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "users",
columns: [
{ type: "modified", name: "email" },
{ type: "added", name: "nickname" },
],
indexes: [{ type: "removed", name: "idx_legacy" }],
},
]),
);
const toggles: Array<{ object: SchemaDiffObject; selected: boolean }> = [];
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(SchemaDiffObjectTree, {
groups,
selectedObjectId: null,
onToggleObjectSelection: (object: SchemaDiffObject, selected: boolean) => toggles.push({ object, selected }),
});
},
}),
);
mountedApps.push(app);
app.mount(host);
await nextTick();
expect(host.textContent).toContain("diff.operationLabel.create");
expect(host.textContent).toContain("diff.operationLabel.delete");
expect(host.textContent).not.toContain("database");
const tableExpandButtons = new Set(
Array.from(host.querySelectorAll("span"))
.filter((element) => element.textContent === "users")
.map((element) => element.closest(".grid")?.querySelector("button") as HTMLButtonElement),
);
for (const button of tableExpandButtons) button.click();
await nextTick();
const tableSides = Array.from(host.querySelectorAll("span"))
.filter((element) => element.textContent === "users")
.map((element) => element.parentElement)
.filter((element): element is HTMLElement => element !== null);
expect(tableSides.length).toBeGreaterThan(0);
expect(tableSides.every((element) => element.classList.contains("pl-6"))).toBe(true);
const emailSides = Array.from(host.querySelectorAll("span")).filter((element) => element.textContent === "email");
expect(emailSides).toHaveLength(2);
expect(emailSides.every((element) => element.parentElement?.classList.contains("pl-16"))).toBe(true);
const nickname = Array.from(host.querySelectorAll("span")).find((element) => element.textContent === "nickname");
expect(nickname).toBeTruthy();
const nicknameCheckbox = nickname?.closest(".grid")?.querySelector('input[type="checkbox"]') as HTMLInputElement;
nicknameCheckbox.checked = false;
nicknameCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(toggles.at(-1)?.object.id).toBe("col-users-nickname");
expect(toggles.at(-1)?.selected).toBe(false);
});
});