feat(schema-diff): preserve dialect-specific DDL semantics

This commit is contained in:
dbin0123 authored and GitHub committed 2026-07-29 03:47:19 +08:00
1 parent 820b8cd26f
commit 002a2c85c0
146 files changed
+36532 -1375

No files matched your search

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { CheckCircle2, XCircle, GitMerge } from "@lucide/vue";
const { t } = useI18n();
interface ConflictItem {
objectName: string;
conflictType: string;
sourceValue: string;
targetValue: string;
autoResolvable: boolean;
}
const props = defineProps<{
conflicts: ConflictItem[];
}>();
const emit = defineEmits<{
resolveWithSource: [objectName: string];
resolveWithTarget: [objectName: string];
}>();
</script>
<template>
<div class="border rounded-md bg-card">
<div class="flex items-center gap-2 px-3 py-2 border-b">
<GitMerge class="w-4 h-4 text-primary" />
<h3 class="text-sm font-medium">{{ t("conflictMatrix.title") }}</h3>
</div>
<div v-if="conflicts.length === 0" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
{{ t("conflictMatrix.noConflicts") }}
</div>
<div v-else class="p-3">
<div class="border rounded overflow-hidden">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.objectName") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.conflictType") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.sourceValue") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.targetValue") }}</th>
<th class="text-center px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.autoResolvable") }}</th>
<th class="text-right px-2 py-1.5 font-medium text-muted-foreground">{{ t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="c in conflicts" :key="c.objectName" class="border-t" :class="c.autoResolvable ? 'bg-emerald-500/5' : 'bg-red-500/5'">
<td class="px-2 py-1.5 font-mono">{{ c.objectName }}</td>
<td class="px-2 py-1.5">{{ c.conflictType }}</td>
<td class="px-2 py-1.5 font-mono max-w-[200px] truncate" :title="c.sourceValue">{{ c.sourceValue }}</td>
<td class="px-2 py-1.5 font-mono max-w-[200px] truncate" :title="c.targetValue">{{ c.targetValue }}</td>
<td class="px-2 py-1.5 text-center">
<CheckCircle2 v-if="c.autoResolvable" class="w-4 h-4 text-green-500 inline-block" />
<XCircle v-else class="w-4 h-4 text-red-500 inline-block" />
</td>
<td class="px-2 py-1.5 text-right">
<div v-if="!c.autoResolvable" class="flex items-center justify-end gap-1">
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors" @click="emit('resolveWithSource', c.objectName)">
{{ t("conflictMatrix.resolveWithSource") }}
</button>
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground hover:bg-muted/60 transition-colors" @click="emit('resolveWithTarget', c.objectName)">
{{ t("conflictMatrix.resolveWithTarget") }}
</button>
</div>
<span v-else class="text-[10px] text-green-600 dark:text-green-400 font-medium">
{{ t("conflictMatrix.resolved") }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Download, Upload, Check, X, Trash2 } from "@lucide/vue";
import FieldMappingPanel from "@/components/diff/FieldMappingPanel.vue";
import type { FieldMappingEntry } from "@/types/schemaDiff";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
open: boolean;
mappings: FieldMappingEntry[];
sourceDbType: string;
targetDbType: string;
sourceConnectionId?: string;
sourceDatabase?: string;
targetConnectionId?: string;
targetDatabase?: string;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "save", value: FieldMappingEntry[]): void;
}>();
const { t } = useI18n();
const { toast } = useToast();
const editingMappings = ref<FieldMappingEntry[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
// 打开时深拷贝当前映射为本地副本(单向数据流,取消不写回)
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
editingMappings.value = props.mappings.map((m) => ({ ...m }));
}
},
{ immediate: true },
);
function handleUpdateMappings(v: FieldMappingEntry[]) {
editingMappings.value = v;
}
function handleExport() {
const payload = {
sourceDbType: props.sourceDbType,
targetDbType: props.targetDbType,
mappings: editingMappings.value,
};
const content = JSON.stringify(payload, null, 2);
// Use File System Access API (modern Chromium) for a native Save As dialog,
// fallback to Blob download for other browsers.
if ("showSaveFilePicker" in window) {
(window as any)
.showSaveFilePicker({
suggestedName: "field-mappings.json",
types: [{ description: "JSON", accept: { "application/json": [".json"] } }],
})
.then((handle: FileSystemFileHandle) => {
return handle.createWritable().then((writable: FileSystemWritableFileStream) => {
writable.write(content);
return writable.close();
});
})
.catch(() => {
// User cancelled or API failed — fallback to Blob download
fallbackDownload(content);
});
} else {
fallbackDownload(content);
}
}
function handleClear() {
editingMappings.value = [];
}
function fallbackDownload(content: string) {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "field-mappings.json";
a.click();
URL.revokeObjectURL(url);
}
function handleImportClick() {
fileInputRef.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result)) as any;
// Support both new format { sourceDbType, targetDbType, mappings }
// and legacy flat format FieldMappingEntry[]
let mappings: any[];
let fileSourceDbType: string | undefined;
let fileTargetDbType: string | undefined;
if (Array.isArray(parsed)) {
mappings = parsed;
} else if (parsed?.mappings && Array.isArray(parsed.mappings)) {
mappings = parsed.mappings;
fileSourceDbType = parsed.sourceDbType;
fileTargetDbType = parsed.targetDbType;
} else {
throw new Error("invalid structure");
}
const normalized: FieldMappingEntry[] = mappings.map((item: any) => ({
sourceType: String(item.sourceType ?? ""),
targetType: String(item.targetType ?? ""),
paramStrategy: item.paramStrategy === "strip" || item.paramStrategy === "custom" ? item.paramStrategy : "preserve",
customParams: item.customParams ? String(item.customParams) : undefined,
}));
if (normalized.some((m) => !m.sourceType || !m.targetType)) {
throw new Error("missing sourceType/targetType");
}
// Warn if exported source/target types differ from current
if (fileSourceDbType && fileTargetDbType && (fileSourceDbType !== props.sourceDbType || fileTargetDbType !== props.targetDbType)) {
toast(t("diff.fieldMapping.importTypeMismatch", { src: fileSourceDbType, tgt: fileTargetDbType }), 5000);
}
editingMappings.value = normalized;
toast(t("diff.fieldMapping.importSuccess"), 2000);
} catch {
toast(t("diff.fieldMapping.importError"), 3000);
} finally {
input.value = "";
}
};
reader.onerror = () => toast(t("diff.fieldMapping.importError"), 3000);
reader.readAsText(file);
}
function handleDone() {
emit(
"save",
editingMappings.value.map((m) => ({ ...m })),
);
emit("update:open", false);
}
function handleClose() {
emit("update:open", false);
}
</script>
<template>
<div v-if="open" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="handleClose">
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium">{{ t("diff.fieldMapping.title") }}</h3>
<Button variant="ghost" size="sm" @click="handleClose">
<X class="w-3.5 h-3.5" />
</Button>
</div>
<FieldMappingPanel
:mappings="editingMappings"
:source-db-type="sourceDbType"
:target-db-type="targetDbType"
:source-connection-id="sourceConnectionId"
:source-database="sourceDatabase"
:target-connection-id="targetConnectionId"
:target-database="targetDatabase"
@update:mappings="handleUpdateMappings"
/>
<div class="flex items-center justify-between mt-4 pt-3 border-t">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="handleClear" :disabled="editingMappings.length === 0">
<Trash2 class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.clearMappings") }}
</Button>
<Button variant="outline" size="sm" @click="handleImportClick">
<Upload class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.import") }}
</Button>
<Button variant="outline" size="sm" @click="handleExport">
<Download class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.export") }}
</Button>
</div>
<Button size="sm" @click="handleDone">
<Check class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.done") }}
</Button>
</div>
<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden" @change="onFileSelected" />
</div>
</div>
</template>
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ArrowRight, Wand2 } from "@lucide/vue";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { findPreset } from "@/lib/fieldMappingPresets";
import type { FieldMappingEntry, FieldMappingParamStrategy } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
const store = useConnectionStore();
const props = defineProps<{
mappings: FieldMappingEntry[];
sourceDbType: string;
targetDbType: string;
/** Same source as table structure editor: live types via listDataTypes(connection, database). */
sourceConnectionId?: string;
sourceDatabase?: string;
targetConnectionId?: string;
targetDatabase?: string;
}>();
const emit = defineEmits<{
(e: "update:mappings", value: FieldMappingEntry[]): void;
}>();
const sameType = computed(() => props.sourceDbType === props.targetDbType);
const sourceTypeOptions = ref<string[]>([]);
const targetTypeOptions = ref<string[]>([]);
const selectedPresetId = ref<string>("");
const availablePresets = computed(() => {
const preset = findPreset(props.sourceDbType, props.targetDbType);
return preset ? [preset] : [];
});
/** Same merge semantics as TableStructureEditor (local copy; do not touch structure editor code). */
function mergeDataTypeOptions(primary: readonly string[], fallback: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const option of [...primary, ...fallback]) {
const trimmed = option.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result;
}
/** Match TableStructureEditor: listDataTypes(connection, database) + getDataTypeOptions(dbType). */
async function loadTypeOptions(dbType: string, connectionId?: string, database?: string): Promise<string[]> {
const fallback = getDataTypeOptions((dbType || undefined) as DatabaseType | undefined);
if (!connectionId || !database) {
return fallback;
}
try {
await store.ensureConnected(connectionId);
const live = await api.listDataTypes(connectionId, database);
return mergeDataTypeOptions(live, fallback);
} catch {
return fallback;
}
}
function levenshtein(a: string, b: string): number {
const m = a.length,
n = b.length;
let prev = Array.from({ length: n + 1 }, (_, i) => i);
for (let i = 1; i <= m; i++) {
const curr = [i];
for (let j = 1; j <= n; j++) {
curr[j] = a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? prev[j - 1] : Math.min(prev[j], curr[j - 1], prev[j - 1]) + 1;
}
prev = curr;
}
return prev[n];
}
function nameSimilarity(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
return 1 - levenshtein(a, b) / maxLen;
}
function autoGenerateMappings() {
const srcTypes = sourceTypeOptions.value;
const tgtTypes = targetTypeOptions.value;
if (srcTypes.length === 0 || tgtTypes.length === 0) return;
const used = new Set<string>();
const result: FieldMappingEntry[] = [];
for (const src of srcTypes) {
let bestTarget = "";
let bestSim = 0;
for (const tgt of tgtTypes) {
if (used.has(tgt)) continue;
const sim = nameSimilarity(src, tgt);
if (sim > bestSim) {
bestSim = sim;
bestTarget = tgt;
}
}
if (bestTarget && bestSim >= 0.3) {
result.push({ sourceType: src, targetType: bestTarget, paramStrategy: "preserve" });
used.add(bestTarget);
}
}
if (result.length > 0) {
emit("update:mappings", result);
}
}
async function loadSourceTypes() {
sourceTypeOptions.value = await loadTypeOptions(props.sourceDbType, props.sourceConnectionId, props.sourceDatabase);
}
async function loadTargetTypes() {
targetTypeOptions.value = await loadTypeOptions(props.targetDbType, props.targetConnectionId, props.targetDatabase);
}
function addMapping() {
emit("update:mappings", [...props.mappings, { sourceType: "", targetType: "", paramStrategy: "preserve" as FieldMappingParamStrategy }]);
}
function removeMapping(index: number) {
const next = props.mappings.filter((_, i) => i !== index);
emit("update:mappings", next);
}
function updateMapping(index: number, field: keyof FieldMappingEntry, value: any) {
const next = props.mappings.map((m, i) => (i === index ? { ...m, [field]: value } : m));
emit("update:mappings", next);
}
function applyPreset(presetId: string) {
selectedPresetId.value = presetId;
const preset = findPreset(props.sourceDbType, props.targetDbType);
if (preset) {
emit("update:mappings", [...preset.mappings]);
}
}
watch(
() => [props.sourceDbType, props.sourceConnectionId, props.sourceDatabase] as const,
() => {
void loadSourceTypes();
},
);
watch(
() => [props.targetDbType, props.targetConnectionId, props.targetDatabase] as const,
() => {
void loadTargetTypes();
},
);
// Auto-generate mappings when both type lists are ready, no preset exists, and no mappings yet
watch([sourceTypeOptions, targetTypeOptions, availablePresets], ([src, tgt, presets]) => {
if (src.length > 0 && tgt.length > 0 && props.mappings.length === 0 && presets.length === 0 && !sameType.value) {
autoGenerateMappings();
}
});
onMounted(() => {
void loadSourceTypes();
void loadTargetTypes();
});
</script>
<template>
<div class="border rounded-lg bg-card">
<div class="flex items-center gap-2 px-3 py-2 border-b">
<span class="text-xs font-medium">{{ t("diff.fieldMapping.title") }}</span>
<span v-if="!sameType" class="flex items-center gap-1 ml-1">
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ sourceDbType }}</span>
<ArrowRight class="w-3 h-3 text-muted-foreground" />
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ targetDbType }}</span>
</span>
</div>
<div v-if="sameType" class="px-3 py-4 text-xs text-muted-foreground text-center">
{{ t("diff.fieldMapping.sameTypeHint") }}
</div>
<div v-else class="p-3 max-h-[42vh] overflow-auto">
<div v-if="availablePresets.length > 0" class="mb-3">
<label class="text-[10px] font-medium text-muted-foreground mb-1 block">
{{ t("diff.fieldMapping.preset") }}
</label>
<Select :model-value="selectedPresetId" @update:model-value="(v: any) => applyPreset(v)">
<SelectTrigger class="h-8 w-full text-xs">
<SelectValue :placeholder="t('diff.fieldMapping.selectPreset')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="p in availablePresets" :key="p.id" :value="p.id">{{ p.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="mappings.length === 0" class="flex flex-col items-center justify-center py-6 gap-3">
<p class="text-xs text-muted-foreground">{{ t("diff.fieldMapping.noMappings") }}</p>
<Button v-if="availablePresets.length === 0" variant="outline" size="sm" class="h-7 text-xs gap-1" @click="autoGenerateMappings" :disabled="sameType || sourceTypeOptions.length === 0 || targetTypeOptions.length === 0">
<Wand2 class="w-3.5 h-3.5" />
{{ t("diff.fieldMapping.autoGenerate") }}
</Button>
</div>
<div v-else class="space-y-3">
<div class="grid grid-cols-[1fr_auto_1fr_100px_auto] gap-2 items-center px-1">
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.sourceType") }}</span>
<div />
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.targetType") }}</span>
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.paramStrategy") }}</span>
<div />
</div>
<div v-for="(mapping, i) in mappings" :key="i" class="space-y-1">
<div class="grid grid-cols-[1fr_auto_1fr_100px_auto] gap-2 items-center">
<SearchableSelect
:model-value="mapping.sourceType"
@update:model-value="(v: string) => updateMapping(i, 'sourceType', v)"
:options="sourceTypeOptions"
:placeholder="t('diff.fieldMapping.sourceType')"
:search-placeholder="t('diff.fieldMapping.sourceType')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs font-mono"
content-class="w-[var(--reka-popover-trigger-width)]"
allow-custom
/>
<ArrowRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<SearchableSelect
:model-value="mapping.targetType"
@update:model-value="(v: string) => updateMapping(i, 'targetType', v)"
:options="targetTypeOptions"
:placeholder="t('diff.fieldMapping.targetType')"
:search-placeholder="t('diff.fieldMapping.targetType')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs font-mono"
content-class="w-[var(--reka-popover-trigger-width)]"
allow-custom
/>
<Select :model-value="mapping.paramStrategy" @update:model-value="(v: any) => updateMapping(i, 'paramStrategy', v)">
<SelectTrigger class="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preserve">{{ t("diff.fieldMapping.strategyPreserve") }}</SelectItem>
<SelectItem value="strip">{{ t("diff.fieldMapping.strategyStrip") }}</SelectItem>
<SelectItem value="custom">{{ t("diff.fieldMapping.strategyCustom") }}</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="icon-sm" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" @click="removeMapping(i)">
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
<div v-if="mapping.paramStrategy === 'custom'" class="flex items-center gap-2 pl-1">
<input
:value="mapping.customParams || ''"
@input="(e: any) => updateMapping(i, 'customParams', (e.target as HTMLInputElement).value)"
class="h-7 w-32 rounded border border-input bg-background px-2 text-xs font-mono outline-none focus:ring-1 focus:ring-ring"
:placeholder="t('diff.fieldMapping.customParamsPlaceholder')"
/>
</div>
</div>
</div>
<div class="mt-3">
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="addMapping">
<Plus class="w-3.5 h-3.5" />
{{ t("diff.fieldMapping.addMapping") }}
</Button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Clock, WifiOff, Zap, Layers, CheckCircle2, XCircle } from "@lucide/vue";
const { t } = useI18n();
interface ImpactReport {
overallRisk: "safe" | "caution" | "dangerous" | "blocked";
ddlRiskLevel: "safe" | "caution" | "dangerous" | "blocked";
estimatedLocks: Array<{ lockType: string; objects: string[]; duration: string }>;
estimatedTotalDuration: string;
recommendedStrategy: "online" | "lazy" | "offline" | "batch";
warnings: string[];
requiresMaintenanceWindow: boolean;
isReversible: boolean;
}
const props = defineProps<{
report: ImpactReport | null;
}>();
const riskColors: Record<string, string> = {
safe: "#22c55e",
caution: "#eab308",
dangerous: "#f97316",
blocked: "#ef4444",
};
const riskLabels: Record<string, string> = {
safe: t("impact.riskSafe"),
caution: t("impact.riskCaution"),
dangerous: t("impact.riskDangerous"),
blocked: t("impact.riskBlocked"),
};
const strategyIcons: Record<string, any> = {
online: Zap,
lazy: Clock,
offline: WifiOff,
batch: Layers,
};
const strategyLabels: Record<string, string> = {
online: t("impact.strategyOnline"),
lazy: t("impact.strategyLazy"),
offline: t("impact.strategyOffline"),
batch: t("impact.strategyBatch"),
};
const strategyIcon = computed(() => (props.report ? strategyIcons[props.report.recommendedStrategy] : null));
const strategyLabel = computed(() => (props.report ? strategyLabels[props.report.recommendedStrategy] : ""));
</script>
<template>
<div class="border rounded-md bg-card">
<!-- Header -->
<div class="px-3 py-2 border-b">
<h3 class="text-sm font-medium">{{ t("impact.title") }}</h3>
</div>
<!-- No report state -->
<div v-if="!report" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
{{ t("impact.noReport") }}
</div>
<!-- Report content -->
<div v-else class="p-3 space-y-4">
<!-- Risk Summary -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ t("impact.overallRisk") }}:</span>
<span class="px-2 py-0.5 rounded text-[11px] font-semibold text-white" :style="{ backgroundColor: riskColors[report.overallRisk] }">
{{ riskLabels[report.overallRisk] }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ t("impact.ddlRisk") }}:</span>
<span class="px-2 py-0.5 rounded text-[11px] font-semibold text-white" :style="{ backgroundColor: riskColors[report.ddlRiskLevel] }">
{{ riskLabels[report.ddlRiskLevel] }}
</span>
</div>
</div>
<!-- Strategy & Impact Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
<component :is="strategyIcon" class="w-4 h-4 text-primary shrink-0" />
<div class="min-w-0">
<div class="text-[10px] text-muted-foreground">{{ t("impact.recommendedStrategy") }}</div>
<div class="text-xs font-medium truncate">{{ strategyLabel }}</div>
</div>
</div>
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
<Clock class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-[10px] text-muted-foreground">{{ t("impact.estimatedDuration") }}</div>
<div class="text-xs font-medium truncate">{{ report.estimatedTotalDuration }}</div>
</div>
</div>
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
<component :is="report.requiresMaintenanceWindow ? AlertTriangle : CheckCircle2" class="w-4 h-4 shrink-0" :class="report.requiresMaintenanceWindow ? 'text-amber-500' : 'text-green-500'" />
<div class="min-w-0">
<div class="text-[10px] text-muted-foreground">{{ t("impact.maintenanceWindow") }}</div>
<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium" :class="report.requiresMaintenanceWindow ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'">
{{ report.requiresMaintenanceWindow ? t("impact.yes") : t("impact.no") }}
</span>
</div>
</div>
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
<component :is="report.isReversible ? CheckCircle2 : XCircle" class="w-4 h-4 shrink-0" :class="report.isReversible ? 'text-green-500' : 'text-red-500'" />
<div class="min-w-0">
<div class="text-[10px] text-muted-foreground">{{ t("impact.reversible") }}</div>
<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium" :class="report.isReversible ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'">
{{ report.isReversible ? t("impact.yes") : t("impact.no") }}
</span>
</div>
</div>
</div>
<!-- Lock Analysis -->
<div v-if="report.estimatedLocks.length > 0">
<h4 class="text-xs font-medium mb-2">{{ t("impact.lockAnalysis") }}</h4>
<div class="border rounded overflow-hidden">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockType") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockObjects") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockDuration") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(lock, i) in report.estimatedLocks" :key="i" class="border-t">
<td class="px-2 py-1.5 font-mono">{{ lock.lockType }}</td>
<td class="px-2 py-1.5">
<span v-for="(obj, j) in lock.objects" :key="j" class="inline-block mr-1 mb-0.5 px-1.5 py-0.5 rounded bg-muted/40 text-[10px] font-mono">
{{ obj }}
</span>
</td>
<td class="px-2 py-1.5 text-muted-foreground">{{ lock.duration }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Warnings -->
<div v-if="report.warnings.length > 0">
<h4 class="text-xs font-medium mb-2">{{ t("impact.warnings") }}</h4>
<div class="space-y-1">
<div v-for="(warning, i) in report.warnings" :key="i" class="flex items-start gap-2 px-2 py-1.5 rounded text-xs bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/30">
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<span>{{ warning }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { AlertTriangle, CheckCircle, Clock, Hourglass, Loader2, XCircle } from "@lucide/vue";
type OscExecutionStatus = "preparing" | "copying" | "cut_over" | "completed" | "failed" | "postponed";
interface OscOperation {
toolType: "gh-ost" | "pt-osc";
tableName: string;
status: OscExecutionStatus;
progressPercent: number;
estimatedRemainingSecs?: number;
error?: string;
}
const props = defineProps<{
operations: OscOperation[];
}>();
const { t } = useI18n();
const statusConfig: Record<OscExecutionStatus, { labelKey: string; icon: any; badgeClass: string; barClass: string }> = {
preparing: {
labelKey: "osc.preparing",
icon: Hourglass,
badgeClass: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
barClass: "bg-yellow-500",
},
copying: {
labelKey: "osc.copying",
icon: Loader2,
badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
barClass: "bg-blue-500",
},
cut_over: {
labelKey: "osc.cutOver",
icon: Clock,
badgeClass: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
barClass: "bg-purple-500",
},
completed: {
labelKey: "osc.completed",
icon: CheckCircle,
badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
barClass: "bg-green-500",
},
failed: {
labelKey: "osc.failed",
icon: XCircle,
badgeClass: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
barClass: "bg-red-500",
},
postponed: {
labelKey: "osc.postponed",
icon: AlertTriangle,
badgeClass: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
barClass: "bg-orange-500",
},
};
function formatRemaining(seconds?: number): string {
if (seconds == null) return "-";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
const toolTypeLabelKey: Record<string, string> = {
"gh-ost": "osc.ghost",
"pt-osc": "osc.ptosc",
};
</script>
<template>
<div class="rounded-md border bg-card">
<div class="border-b px-4 py-3">
<h3 class="text-sm font-medium">{{ t("osc.title") }}</h3>
</div>
<div v-if="operations.length === 0" class="flex items-center justify-center px-4 py-12 text-xs text-muted-foreground">
{{ t("osc.noActiveOsc") }}
</div>
<div v-else class="divide-y">
<div v-for="op in operations" :key="`${op.toolType}-${op.tableName}`" class="px-4 py-3">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<component :is="statusConfig[op.status].icon" class="h-4 w-4" :class="{ 'animate-spin': op.status === 'copying' }" />
<span class="text-xs font-medium">{{ op.tableName }}</span>
<Badge variant="outline" class="text-[10px]">{{ t(toolTypeLabelKey[op.toolType] || op.toolType) }}</Badge>
</div>
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium" :class="statusConfig[op.status].badgeClass">
<component :is="statusConfig[op.status].icon" class="h-3 w-3" />
{{ t(statusConfig[op.status].labelKey) }}
</span>
</div>
<div class="mb-1.5 flex items-center gap-3">
<div class="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full transition-all duration-500" :class="statusConfig[op.status].barClass" :style="{ width: `${Math.min(op.progressPercent, 100)}%` }" />
</div>
<span class="w-10 text-right text-[10px] tabular-nums text-muted-foreground">{{ Math.min(op.progressPercent, 100) }}%</span>
</div>
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
<span>{{ t("osc.estimatedRemaining") }}: {{ formatRemaining(op.estimatedRemainingSecs) }}</span>
</div>
</div>
<div v-if="op.status === 'failed' && op.error" class="mt-2 rounded-md bg-destructive/10 px-2.5 py-1.5 text-[10px] text-destructive">
<div class="flex items-start gap-1.5">
<AlertTriangle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{{ op.error }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { AlertTriangle, Check, Clock, Layers, SkipForward } from "@lucide/vue";
interface ConflictItem {
objectName: string;
conflictType: string;
sourceValue: string;
targetValue: string;
autoResolvable: boolean;
}
interface RebaseInfo {
id: string;
baselineId: string;
totalObjects: number;
autoResolvedCount: number;
conflictCount: number;
conflicts: ConflictItem[];
createdAt: string;
}
const props = defineProps<{
rebaseInfo: RebaseInfo | null;
}>();
const emit = defineEmits<{
overwriteBaseline: [];
skipObject: [objectName: string];
}>();
const { t } = useI18n();
function formatDate(iso: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(iso));
} catch {
return iso;
}
}
</script>
<template>
<div class="rounded-md border bg-card">
<div class="border-b px-4 py-3">
<h3 class="text-sm font-medium">{{ t("rebase.title") }}</h3>
</div>
<div v-if="!rebaseInfo" class="flex items-center justify-center px-4 py-12 text-xs text-muted-foreground">
{{ t("rebase.notes") }}
</div>
<template v-else>
<div class="grid grid-cols-4 gap-4 border-b px-4 py-3">
<div class="space-y-1">
<div class="text-xs text-muted-foreground">{{ t("rebase.objectCount") }}</div>
<div class="text-sm font-medium">{{ rebaseInfo.totalObjects }}</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">{{ t("rebase.autoResolved") }}</div>
<div class="text-sm font-medium text-green-600 dark:text-green-400">{{ rebaseInfo.autoResolvedCount }}</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">{{ t("rebase.conflictCount") }}</div>
<div class="text-sm font-medium" :class="rebaseInfo.conflictCount > 0 ? 'text-destructive' : ''">{{ rebaseInfo.conflictCount }}</div>
</div>
<div class="space-y-1">
<div class="text-xs text-muted-foreground">{{ t("rebase.notes") }}</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock class="h-3 w-3" />
{{ formatDate(rebaseInfo.createdAt) }}
</div>
</div>
</div>
<div class="border-b px-4 py-3">
<div class="mb-2 flex items-center gap-2">
<Layers class="h-4 w-4 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground">{{ t("rebase.driftReport") }}</span>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{{ t("rebase.currentBaseline") }}</span>
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{{ rebaseInfo.baselineId }}</code>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{{ t("rebase.newBaseline") }}</span>
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{{ rebaseInfo.id }}</code>
</div>
</div>
</div>
<div v-if="rebaseInfo.conflicts.length > 0" class="border-b px-4 py-3">
<div class="mb-2 flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-amber-500" />
<span class="text-xs font-medium text-muted-foreground">{{ t("rebase.conflicts") }}</span>
<Badge variant="destructive" class="ml-auto text-xs">{{ rebaseInfo.conflictCount }}</Badge>
</div>
<div class="space-y-2">
<div v-for="(conflict, index) in rebaseInfo.conflicts" :key="conflict.objectName" class="rounded-md border px-3 py-2 text-xs">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium" :class="conflict.autoResolvable ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'">
{{ index + 1 }}
</div>
<span class="font-medium">{{ conflict.objectName }}</span>
<Badge variant="outline" class="text-[10px]">{{ conflict.conflictType }}</Badge>
<Badge v-if="conflict.autoResolvable" variant="secondary" class="text-[10px]"> auto </Badge>
</div>
<Button size="sm" variant="ghost" class="h-7 gap-1 text-xs" @click="emit('skipObject', conflict.objectName)">
<SkipForward class="h-3 w-3" />
{{ t("rebase.skipObject") }}
</Button>
</div>
<div class="mt-1.5 grid grid-cols-2 gap-3">
<div>
<div class="text-[10px] text-muted-foreground">Source</div>
<code class="mt-0.5 block truncate rounded bg-muted/50 px-1 py-0.5 font-mono">{{ conflict.sourceValue }}</code>
</div>
<div>
<div class="text-[10px] text-muted-foreground">Target</div>
<code class="mt-0.5 block truncate rounded bg-muted/50 px-1 py-0.5 font-mono">{{ conflict.targetValue }}</code>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 px-4 py-3">
<Button size="sm" class="gap-1.5 text-xs" @click="emit('overwriteBaseline')">
<Check class="h-3.5 w-3.5" />
{{ t("rebase.overwriteBaseline") }}
</Button>
</div>
</template>
</div>
</template>
<style scoped></style>
@@ -11,7 +11,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry } from "@/types/schemaDiff";
const { t } = useI18n();
const store = useConnectionStore();
@@ -39,6 +39,8 @@ const emit = defineEmits<{
(e: "update:targetDatabase", value: string): void;
(e: "update:targetSchema", value: string): void;
(e: "update:ignoreComments", value: boolean): void;
(e: "update:fieldMappings", value: FieldMappingEntry[]): void;
(e: "open-field-mapping"): void;
(e: "compare"): void;
(e: "saveConfig"): void;
(e: "loadConfig"): void;
@@ -60,6 +62,11 @@ const sqlConnections = computed(() => store.connections.filter((c: any) => !["mo
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
const sourceDbType = computed(() => sourceConfig.value?.db_type || "");
const targetDbType = computed(() => targetConfig.value?.db_type || "");
const showFieldMapping = computed(() => sourceDbType.value && targetDbType.value && sourceDbType.value !== targetDbType.value);
const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
const canCompare = computed(() => {
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
});
@@ -473,6 +480,11 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<Settings class="w-3.5 h-3.5 mr-1" />
{{ t("diff.options") }}
</Button>
<Button v-if="showFieldMapping" variant="outline" size="sm" @click="$emit('open-field-mapping')">
<ArrowLeftRight class="w-3.5 h-3.5 mr-1" />
{{ t("diff.openFieldMapping") }}
<span v-if="activeFieldMappings.length > 0" class="ml-1 inline-flex items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">{{ activeFieldMappings.length }}</span>
</Button>
</div>
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
@@ -9,10 +9,10 @@ import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
import { FileCode, ScrollText, Copy, Play } from "@lucide/vue";
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
import type { SchemaDiffObject, CompatibilityWarning, DependencyGraph, PermissionDiff, MissingRollbackObject, RollbackCompleteness } from "@/lib/schema/schemaDiff";
const { t } = useI18n();
const { toast } = useToast();
@@ -36,19 +36,39 @@ const props = defineProps<{
selectedObject: SchemaDiffObject | null;
deploySql: string;
deploySqlAll: string;
compatibilityWarnings?: CompatibilityWarning[];
rollbackSql?: string;
deploySqlMode?: "forward" | "rollback";
dependencyGraph?: DependencyGraph | null;
permissionDiffs?: PermissionDiff[];
rollbackCompleteness?: RollbackCompleteness;
missingRollbackObjects?: MissingRollbackObject[];
canExecute?: boolean;
}>();
const emit = defineEmits<{
executeScript: [];
"update:deploySqlMode": [mode: "forward" | "rollback"];
}>();
const activeTab = ref<"ddl" | "script" | "scriptAll">("ddl");
const activeTab = ref<"ddl" | "script" | "scriptAll" | "warnings" | "depGraph" | "permissions" | "rollbackCompare">("ddl");
const diffContainerRef = ref<HTMLDivElement>();
const leftPaneRef = ref<HTMLDivElement>();
const rightPaneRef = ref<HTMLDivElement>();
const containerSize = ref({ width: 0, height: 0 });
const connectorKey = ref(0);
const rollbackDiffContainerRef = ref<HTMLDivElement>();
const rollbackLeftPaneRef = ref<HTMLDivElement>();
const rollbackRightPaneRef = ref<HTMLDivElement>();
const rollbackContainerSize = ref({ width: 0, height: 0 });
const rollbackConnectorKey = ref(0);
const rollbackHunks = computed(() => {
if (!props.rollbackSql) return [];
return buildHunks(props.deploySql, props.rollbackSql);
});
const hunks = computed(() => {
if (!props.selectedObject?.sourceDdl && !props.selectedObject?.targetDdl) return [];
return buildHunks(props.selectedObject?.sourceDdl || "", props.selectedObject?.targetDdl || "");
@@ -61,6 +81,13 @@ const { syncScroll, measureHunks } = useDiffScrollSync({
hunks,
});
const { syncScroll: rollbackSyncScroll, measureHunks: rollbackMeasureHunks } = useDiffScrollSync({
container: rollbackDiffContainerRef,
leftPane: rollbackLeftPaneRef,
rightPane: rollbackRightPaneRef,
hunks: rollbackHunks,
});
// Cache char-level diff segments so we don't recompute on every render
const modifySegments = computed(() => {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
@@ -77,6 +104,21 @@ const modifySegments = computed(() => {
return map;
});
const rollbackModifySegments = computed(() => {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
for (const hunk of rollbackHunks.value) {
if (hunk.type !== "modify") continue;
for (let i = 0; i < hunk.leftLines.length; i++) {
const left = hunk.leftLines[i];
const right = hunk.rightLines[i];
if (left.isPadding || right.isPadding) continue;
const key = `${hunk.id}:${i}`;
map.set(key, renderModifyLine(left, right));
}
}
return map;
});
let measureRaf: number | null = null;
let measureTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -101,6 +143,37 @@ function handleScroll(from: "left" | "right") {
requestMeasureDebounced();
}
let rollbackMeasureRaf: number | null = null;
let rollbackMeasureTimeout: ReturnType<typeof setTimeout> | null = null;
function rollbackRequestMeasure() {
if (rollbackMeasureRaf) return;
rollbackMeasureRaf = requestAnimationFrame(() => {
rollbackMeasureRaf = null;
rollbackMeasureHunks();
rollbackConnectorKey.value++;
});
}
function rollbackRequestMeasureDebounced() {
if (rollbackMeasureTimeout) clearTimeout(rollbackMeasureTimeout);
rollbackMeasureTimeout = setTimeout(() => {
rollbackRequestMeasure();
}, 100);
}
function rollbackHandleScroll(from: "left" | "right") {
rollbackSyncScroll(from);
rollbackRequestMeasureDebounced();
}
function rollbackUpdateContainerSize() {
const el = rollbackDiffContainerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
rollbackContainerSize.value = { width: rect.width, height: rect.height };
}
watch(
() => props.selectedObject?.id,
async () => {
@@ -122,6 +195,11 @@ function onSplitpanesResized() {
requestMeasure();
}
function rollbackOnSplitpanesResized() {
rollbackUpdateContainerSize();
rollbackRequestMeasure();
}
function lineBackground(line: DiffLine): string | undefined {
if (line.isPadding) return undefined;
if (line.type === "delete") {
@@ -232,6 +310,19 @@ function copyDeploySqlAll() {
<template>
<div class="border rounded-md flex flex-col h-full">
<!-- Forward / Rollback SQL mode toggle -->
<div v-if="rollbackSql" class="flex items-center gap-1 px-3 py-1 border-b bg-muted/20 shrink-0">
<span class="text-[11px] text-muted-foreground mr-2">{{ t("diff.deployMode") }}:</span>
<button class="text-xs px-2 py-1 rounded transition-colors" :class="deploySqlMode === 'forward' ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'forward')">{{ t("diff.forwardSql") }}</button>
<button class="text-xs px-2 py-1 rounded transition-colors" :class="deploySqlMode === 'rollback' ? 'bg-destructive text-destructive-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'rollback')">{{ t("diff.rollbackSql") }}</button>
</div>
<div v-if="rollbackCompleteness === 'incomplete'" class="px-3 py-1.5 border-b bg-amber-50 dark:bg-amber-950/30 text-xs text-amber-800 dark:text-amber-200 shrink-0">
{{ t("diff.rollbackIncompleteBanner") }}
<ul v-if="missingRollbackObjects?.length" class="mt-1 list-disc pl-4">
<li v-for="(m, i) in missingRollbackObjects" :key="i">{{ m.kind }} {{ m.name }}{{ m.table ? ` @ ${m.table}` : "" }} — {{ m.reason }}</li>
</ul>
</div>
<!-- Tabs -->
<div class="flex border-b shrink-0">
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'ddl' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'ddl'">
@@ -246,6 +337,29 @@ function copyDeploySqlAll() {
<ScrollText class="w-3.5 h-3.5" />
{{ t("diff.deployScriptAll") }}
</button>
<button v-if="rollbackSql" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'rollbackCompare' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'rollbackCompare'">
<FileDiff class="w-3.5 h-3.5" />
{{ t("rollbackComparison.title") }}
</button>
<button
v-if="compatibilityWarnings && compatibilityWarnings.length > 0"
class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors relative"
:class="activeTab === 'warnings' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-b-2 border-amber-500' : 'hover:bg-muted/50'"
@click="activeTab = 'warnings'"
>
<span class="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
{{ t("diff.compatibilityWarnings") }}
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-amber-500 text-white font-bold">{{ compatibilityWarnings.length }}</span>
</button>
<button v-if="dependencyGraph && dependencyGraph.nodes.length > 0" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'depGraph' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'depGraph'">
<span class="w-2 h-2 rounded-full bg-blue-500 shrink-0" />
{{ t("diff.dependencyGraph") }}
</button>
<button v-if="permissionDiffs && permissionDiffs.length > 0" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'permissions' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'permissions'">
<span class="w-2 h-2 rounded-full bg-purple-500 shrink-0" />
{{ t("diff.operation") }}
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-purple-500 text-white font-bold">{{ permissionDiffs.length }}</span>
</button>
</div>
<!-- DDL Compare -->
@@ -335,6 +449,82 @@ function copyDeploySqlAll() {
</div>
</div>
<!-- Rollback Comparison -->
<div v-else-if="activeTab === 'rollbackCompare'" class="flex-1 overflow-hidden relative">
<div v-if="!rollbackSql" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.noDdlAvailable") }}
</div>
<div v-else ref="rollbackDiffContainerRef" class="absolute inset-0 font-mono text-xs leading-relaxed">
<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">
{{ 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-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.leftLines.length - 1,
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
<template v-if="line.type === 'modify' && !line.isPadding">
<template v-for="(segment, si) in rollbackModifySegments.get(`${hunk.id}:${idx}`)?.leftSegments ?? []" :key="`ls-${si}`">
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
</template>
</template>
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
</span>
</div>
</div>
</div>
</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">
{{ 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-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.rightLines.length - 1,
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
<template v-if="line.type === 'modify' && !line.isPadding">
<template v-for="(segment, si) in rollbackModifySegments.get(`${hunk.id}:${idx}`)?.rightSegments ?? []" :key="`rs-${si}`">
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
</template>
</template>
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
</span>
</div>
</div>
</div>
</Pane>
</Splitpanes>
<DiffSvgConnector :key="rollbackConnectorKey" :hunks="rollbackHunks" :container-width="rollbackContainerSize.width" :container-height="rollbackContainerSize.height" />
</div>
</div>
<!-- Deploy Script -->
<div v-else-if="activeTab === 'script'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
@@ -344,7 +534,7 @@ function copyDeploySqlAll() {
<Copy class="w-3 h-3" />
{{ t("diff.copy") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" :disabled="canExecute === false" @click="$emit('executeScript')">
<Play class="w-3 h-3" />
{{ t("diff.execute") }}
</Button>
@@ -355,6 +545,63 @@ function copyDeploySqlAll() {
</div>
</div>
<!-- Compatibility Warnings -->
<div v-else-if="activeTab === 'warnings'" class="flex-1 flex flex-col overflow-hidden p-3">
<div v-if="compatibilityWarnings && compatibilityWarnings.length > 0" class="space-y-2">
<div v-for="(w, i) in compatibilityWarnings" :key="i" class="flex items-start gap-2 p-2 rounded border bg-amber-50 dark:bg-amber-950/20 text-xs">
<span class="shrink-0 w-2 h-2 rounded-full mt-0.5" :class="w.risk === 'high' ? 'bg-red-500' : w.risk === 'medium' ? 'bg-amber-500' : 'bg-yellow-400'" />
<div class="min-w-0">
<span class="font-medium">{{ w.table }}.{{ w.column }}</span
>:
<span class="text-muted-foreground">{{ w.sourceType }} → {{ w.targetType }}</span>
<p class="text-muted-foreground mt-0.5">{{ w.message }}</p>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.noDifferences") }}
</div>
</div>
<!-- Dependency Graph -->
<div v-else-if="activeTab === 'depGraph'" class="flex-1 flex flex-col overflow-hidden p-3">
<div v-if="dependencyGraph && dependencyGraph.nodes.length > 0" class="space-y-1">
<div v-for="node in dependencyGraph.nodes" :key="node.tableName" class="py-1 text-xs">
<span class="font-mono font-medium">{{ node.tableName }}</span>
<span v-if="node.dependsOn.length > 0" class="text-muted-foreground ml-2">
→ {{ t("diff.dependsOn") }}: <span class="font-mono">{{ node.dependsOn.join(", ") }}</span>
</span>
<span v-if="node.dependedBy.length > 0" class="text-muted-foreground ml-2">
← {{ t("diff.dependedBy") }}: <span class="font-mono">{{ node.dependedBy.join(", ") }}</span>
</span>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.noDifferences") }}
</div>
</div>
<!-- Permissions -->
<div v-else-if="activeTab === 'permissions'" class="flex-1 flex flex-col overflow-hidden p-3">
<div v-if="permissionDiffs && permissionDiffs.length > 0" class="space-y-2">
<div v-for="(pd, i) in permissionDiffs" :key="i" class="flex items-start gap-2 p-2 rounded border text-xs">
<span class="shrink-0 w-2 h-2 rounded-full mt-0.5 bg-purple-500" />
<div class="min-w-0">
<span class="font-medium">{{ pd.objectName }}</span>
<span class="text-muted-foreground"> — {{ pd.permissionType }}</span>
<div class="mt-0.5">
<span v-if="pd.sourcePermission" class="text-green-600 dark:text-green-400">{{ t("diff.source") }}: {{ pd.sourcePermission }}</span>
<span v-if="pd.sourcePermission && pd.targetPermission" class="text-muted-foreground mx-1">→</span>
<span v-if="pd.targetPermission" class="text-red-600 dark:text-red-400">{{ t("diff.target") }}: {{ pd.targetPermission }}</span>
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.noDifferences") }}
</div>
</div>
<!-- Deploy Script All -->
<div v-else-if="activeTab === 'scriptAll'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
@@ -364,7 +611,7 @@ function copyDeploySqlAll() {
<Copy class="w-3 h-3" />
{{ t("diff.copy") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" :disabled="canExecute === false" @click="$emit('executeScript')">
<Play class="w-3 h-3" />
{{ t("diff.executeAll") }}
</Button>
@@ -9,13 +9,15 @@ import { useTheme } from "@/composables/useTheme";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { Splitpanes, Pane } from "splitpanes";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap } from "@lucide/vue";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind, CompatibilityWarning, RenameCandidate, MissingRollbackObject, 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, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const { isDark, themePalette } = useTheme();
const { isDark } = useTheme();
const props = defineProps<{
deploySql: string;
@@ -24,10 +26,19 @@ const props = defineProps<{
targetDatabase: string;
targetSchema: string;
executing: boolean;
rollbackSql?: string;
deploySqlMode?: "forward" | "rollback";
compatibilityWarnings?: CompatibilityWarning[];
renameCandidates?: RenameCandidate[];
impactReport?: ImpactReport | null;
rollbackCompleteness?: RollbackCompleteness;
missingRollbackObjects?: MissingRollbackObject[];
canExecute?: boolean;
}>();
const emit = defineEmits<{
"update:deploySql": [sql: string];
"update:deploySqlMode": [mode: "forward" | "rollback"];
back: [];
deploy: [];
}>();
@@ -92,6 +103,24 @@ const operationCounts = computed(() => {
return counts;
});
const riskLevel = computed(() => {
const count = props.compatibilityWarnings?.length ?? 0;
if (count === 0) return "safe";
if (count <= 3) return "caution";
return "dangerous";
});
const renameCount = computed(() => props.renameCandidates?.length ?? 0);
const showImpactReport = ref(false);
const effectiveSql = computed(() => {
if (props.deploySqlMode === "rollback" && props.rollbackSql) {
return props.rollbackSql;
}
return props.deploySql;
});
const operationIcons: Record<DiffOperationType, any> = {
modify: ArrowRightLeft,
create: PlusCircle,
@@ -120,13 +149,13 @@ async function initEditor() {
const fontSize = settingsStore.editorSettings.fontSize;
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance, undefined, themePalette.value);
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres");
const state = EditorState.create({
doc: props.deploySql,
doc: effectiveSql.value,
extensions: [
basicSetup,
langSql.sql({ dialect }),
@@ -144,9 +173,9 @@ async function initEditor() {
isEditorReady.value = true;
}
// Watch for external deploySql changes
// Watch for external deploySql changes (including mode switch)
watch(
() => props.deploySql,
() => effectiveSql.value,
(newVal) => {
if (editorView.value && editorView.value.state.doc.toString() !== newVal) {
editorView.value.dispatch({
@@ -271,14 +300,38 @@ function getObjectIconColor(kind: DiffObjectKind): string {
</Button>
<span class="text-sm font-medium">{{ t("diff.deployReview") }}</span>
<span class="text-xs text-muted-foreground"> ({{ t("diff.selectedCount", { selected: topLevelObjects.length, total: topLevelObjects.length }) }}) </span>
<!-- Risk level badge -->
<span v-if="riskLevel !== 'safe'" class="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium" :class="riskLevel === 'dangerous' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'">
<AlertTriangle class="w-3 h-3" />
{{ riskLevel === "dangerous" ? t("diff.riskLevel.dangerous", { count: compatibilityWarnings?.length ?? 0 }) : t("diff.riskLevel.caution", { count: compatibilityWarnings?.length ?? 0 }) }}
</span>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="text-green-500">{{ t("diff.create") }}: {{ operationCounts.create }}</span>
<span class="text-blue-500">{{ t("diff.modify") }}: {{ operationCounts.modify }}</span>
<span class="text-red-500">{{ t("diff.delete") }}: {{ operationCounts.delete }}</span>
<span v-if="renameCount > 0" class="text-purple-500">{{ t("diff.renameCount", { count: renameCount }) }}</span>
</div>
</div>
<!-- Impact Report toggle -->
<div v-if="impactReport" class="border-b shrink-0">
<button class="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-accent/50 transition-colors" @click="showImpactReport = !showImpactReport">
<ShieldCheck class="w-3.5 h-3.5" :class="impactReport.overallRisk === 'safe' ? 'text-green-500' : impactReport.overallRisk === 'caution' ? 'text-yellow-500' : impactReport.overallRisk === 'dangerous' ? 'text-orange-500' : 'text-red-500'" />
<span class="font-medium">{{ t("impact.title") }}</span>
<span class="ml-auto text-muted-foreground">{{ showImpactReport ? "▲" : "▼" }}</span>
</button>
<div v-if="showImpactReport" class="border-t">
<ImpactReportPanel :report="impactReport" />
</div>
</div>
<div v-if="rollbackCompleteness === 'incomplete' && deploySqlMode === 'rollback'" class="px-3 py-1.5 border-b bg-amber-50 dark:bg-amber-950/30 text-xs text-amber-800 dark:text-amber-200 shrink-0">
{{ t("diff.rollbackIncompleteBanner") }}
<ul v-if="missingRollbackObjects?.length" class="mt-1 list-disc pl-4">
<li v-for="(m, i) in missingRollbackObjects" :key="i">{{ m.kind }} {{ m.name }}{{ m.table ? ` @ ${m.table}` : "" }} — {{ m.reason }}</li>
</ul>
</div>
<!-- Content -->
<Splitpanes class="flex-1 min-h-0">
<Pane size="30" min-size="20">
@@ -305,6 +358,11 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<!-- Footer -->
<div class="flex items-center justify-between px-3 py-2 border-t shrink-0 gap-2">
<div class="flex items-center gap-2">
<!-- SQL mode toggle -->
<div v-if="props.rollbackSql" class="flex items-center gap-1 mr-2 border rounded">
<button class="text-xs px-2 py-1 rounded transition-colors" :class="props.deploySqlMode === 'forward' ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'forward')">{{ t("diff.deployMode") }}</button>
<button class="text-xs px-2 py-1 rounded transition-colors" :class="props.deploySqlMode === 'rollback' ? 'bg-destructive text-destructive-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'rollback')">{{ t("diff.rollbackMode") }}</button>
</div>
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="handleCopy">
<Copy class="w-3.5 h-3.5" />
{{ t("diff.copyScript") }}
@@ -322,7 +380,7 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="$emit('back')">
{{ t("diff.cancel") }}
</Button>
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing" @click="handleDeploy">
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing || canExecute === false" @click="handleDeploy">
<Loader2 v-if="executing" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
{{ t("diff.deployToServer") }}
@@ -5,22 +5,44 @@ import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "
import { Button } from "@/components/ui/button";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck, ChevronDown, ChevronRight } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
import SchemaDiffDdlPanel from "@/components/diff/SchemaDiffDdlPanel.vue";
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue";
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, shouldFetchSchemaDiffDdl } from "@/lib/schema/schemaDiffMetadataLoad";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
import type { ObjectSourceKind, TableInfo } from "@/types/database";
import { buildDeploySqlForObjects, convertToSchemaDiffObjects, groupDiffObjects, schemaDiffDeployTargetSchema, type OperationGroup, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type SchemaDiffPreparation, type TableSchemaDetail } from "@/lib/schema/schemaDiff";
import {
buildDeploySqlForObjects,
convertToSchemaDiffObjects,
groupDiffObjects,
injectColumnRenameSql,
schemaDiffDeployTargetSchema,
databaseTypeToDialectKind,
normalizeDialectKind,
type OperationGroup,
type SchemaDiffObject,
type DiffOperationType,
type DiffObjectKind,
type SchemaDiffPreparation,
type MissingRollbackObject,
type RollbackCompleteness,
type TableSchemaDetail,
type RenameCandidate,
type CompatibilityWarning,
type PermissionDiff,
type DependencyGraph,
} from "@/lib/schema/schemaDiff";
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -53,6 +75,16 @@ const ignoreComments = ref(false);
// Options panel
const showOptionsPanel = ref(false);
const showFieldMappingDialog = ref(false);
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
// Clear stale field mappings when source and target are the same type
watch([sourceDbType, targetDbType], ([src, tgt]) => {
if (src && src === tgt && activeConfig.value?.options.fieldMappings?.length) {
handleFieldMappingsUpdate([]);
}
});
const optionTree = computed(() => {
const targetConfig = store.getConfig(targetConnectionId.value);
const dbType = targetConfig?.db_type || "postgres";
@@ -70,7 +102,22 @@ const executing = ref(false);
const lastDiffResult = ref<SchemaDiffPreparation | null>(null);
const targetDbVersion = ref<string | null>(null);
const showResultDialog = ref(false);
const deployResult = ref<{ success: boolean; message: string; affectedRows?: number } | null>(null);
const deployResult = ref<{ success: boolean; status?: string; message: string; affectedRows?: number; error?: string } | null>(null);
// Phase 4 result fields
const rollbackSql = ref("");
const rollbackCompleteness = ref<RollbackCompleteness>("complete");
const missingRollbackObjects = ref<MissingRollbackObject[]>([]);
const renameCandidates = ref<RenameCandidate[]>([]);
const compatibilityWarnings = ref<CompatibilityWarning[]>([]);
const permissionDiffs = ref<PermissionDiff[]>([]);
const dependencyGraph = ref<DependencyGraph | null>(null);
// Rename candidates panel
const showRenamePanel = ref(true);
// Rollback / forward SQL mode in deploy step
const deploySqlMode = ref<"forward" | "rollback">("forward");
// Dialog size memory (width + height + splitpanes ratio)
const DIALOG_SIZE_KEY = "dbx-schema-diff-size";
@@ -274,6 +321,13 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
}
}
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
if (activeConfig.value) {
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(updated, getDbType()));
}
}
/** Map a JDBC table_type to an ObjectSourceKind for getTableDdl routing.
* Views and materialized views need the object_type parameter so the
* backend can call DBMS_METADATA.GET_DDL with the correct type. */
@@ -324,6 +378,15 @@ async function handleCompare() {
loading.value = true;
step.value = "compare";
// Reset Phase 4 state to prevent stale data from previous compares
rollbackSql.value = "";
rollbackCompleteness.value = "complete";
missingRollbackObjects.value = [];
renameCandidates.value = [];
compatibilityWarnings.value = [];
permissionDiffs.value = [];
dependencyGraph.value = null;
try {
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
@@ -404,17 +467,53 @@ async function handleCompare() {
ignoreComments: ignoreComments.value,
cascadeDelete: opts?.cascadeDelete ?? false,
compareColumnOrder: opts.compareColumnOrder,
detectRenames: opts?.detectRenames ?? false,
detectTableRenames: opts?.detectTableRenames ?? false,
renameThreshold: opts?.renameThreshold ?? 0.5,
enableRollback: opts?.enableRollback ?? false,
batchPatterns: opts?.batchPatterns
? opts.batchPatterns
.split(",")
.map((s: string) => s.trim())
.filter(Boolean)
: undefined,
sourceDialect: opts?.sourceDialect ? normalizeDialectKind(opts.sourceDialect) : sourceConfig?.db_type ? databaseTypeToDialectKind(sourceConfig.db_type) : undefined,
targetDialect: opts?.targetDialect ? normalizeDialectKind(opts.targetDialect) : targetConfig?.db_type ? databaseTypeToDialectKind(targetConfig.db_type) : undefined,
compatibilityThreshold: opts?.compatibilityThreshold ?? 0.5,
fieldMappings:
opts?.fieldMappings?.map((m: FieldMappingEntry) => ({
sourceType: m.sourceType,
targetType: m.targetType,
paramStrategy: m.paramStrategy ?? "preserve",
customParams: m.customParams,
})) || [],
});
// Extract new result fields
rollbackSql.value = result.rollbackSyncSql ?? "";
rollbackCompleteness.value = result.rollbackCompleteness ?? "complete";
missingRollbackObjects.value = result.missingRollbackObjects ?? [];
renameCandidates.value = result.renameCandidates ?? [];
compatibilityWarnings.value = result.compatibilityWarnings ?? [];
permissionDiffs.value = result.permissionDiffs ?? [];
dependencyGraph.value = result.dependencyGraph ?? null;
// Convert to unified objects
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs);
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates);
// Group by operation type and object kind
diffGroups.value = groupDiffObjects(diffObjects.value);
// Save full result and generate deploy SQL
// Save full result and apply column rename detection to SQL
lastDiffResult.value = result;
deploySqlAll.value = result.syncSql;
if (opts?.detectRenames && opts.renameThreshold) {
deploySqlAll.value = injectColumnRenameSql(deploySqlAll.value, result.diffs, opts.renameThreshold);
if (rollbackSql.value) {
rollbackSql.value = injectColumnRenameSql(rollbackSql.value, result.diffs, opts.renameThreshold, true);
}
}
deploySqlMode.value = "forward";
regenerateDeploySql();
step.value = "result";
@@ -498,29 +597,112 @@ function regenerateDeploySql() {
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
}
function switchDeploySqlMode(mode: "forward" | "rollback") {
if (mode === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 4000);
return;
}
deploySqlMode.value = mode;
if (mode === "rollback" && rollbackSql.value) {
deploySql.value = rollbackSql.value;
} else {
regenerateDeploySql();
}
}
const canExecuteDeploy = computed(() => {
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
return false;
}
return true;
});
function applyRename(rc: RenameCandidate) {
let found = false;
for (const obj of diffObjects.value) {
// Backend-detected renamed diff (diff_type = "renamed")
if (obj.renameMetadata?.sourceName === rc.sourceName && obj.renameMetadata?.targetName === rc.targetName) {
obj.renameMetadata.confirmed = true;
obj.selected = true;
found = true;
}
// Legacy delete+create pair
if (obj.operationType === "delete" && obj.name === rc.sourceName) {
obj.deploySql = `-- Renamed to ${rc.targetName}\n${obj.deploySql ?? ""}`;
obj.renameMetadata = { confirmed: true, targetName: rc.targetName, score: rc.score };
found = true;
}
if (obj.operationType === "create" && obj.name === rc.targetName) {
obj.deploySql = `-- Renamed from ${rc.sourceName}\n${obj.deploySql ?? ""}`;
obj.sourceName = rc.sourceName;
obj.renameMetadata = { confirmed: true, sourceName: rc.sourceName, score: rc.score };
found = true;
}
}
if (found) {
regenerateDeploySql();
toast(t("diff.renameApplied"), 2000);
}
}
function ignoreRename(index: number) {
const rc = renameCandidates.value[index];
if (rc) {
for (const obj of diffObjects.value) {
if (obj.renameMetadata?.sourceName === rc.sourceName && obj.renameMetadata?.targetName === rc.targetName) {
obj.renameMetadata.confirmed = false;
obj.selected = false;
}
}
}
renameCandidates.value.splice(index, 1);
regenerateDeploySql();
}
async function handleExecuteScript() {
if (!deploySql.value || deploySql.value.startsWith("-- ")) {
toast(t("diff.noObjectsSelected"), 3000);
return;
}
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 5000);
return;
}
await executeDeploySql();
}
async function executeDeploySql() {
executing.value = true;
try {
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
const targetConnection = store.getConfig(targetConnectionId.value);
const failed = await executeWithProductionSqlGuard({
connection: targetConnection,
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
execute: async () => {
const txLog = await api.executeScriptWith2pc(targetConnectionId.value, targetDatabase.value, [deploySql.value], targetSchema.value);
return txLog;
},
});
if (!result) return;
toast(t("diff.executeSuccess"), 3000);
if (failed === undefined) return;
showDeployTxResult(failed);
} catch (e: any) {
toast(e?.message || String(e), 5000);
deployResult.value = {
success: false,
message: e?.message || String(e),
};
showResultDialog.value = true;
} finally {
executing.value = false;
}
}
function showDeployTxResult(txLog: any) {
deployResult.value = buildDeployTxResult(txLog, t);
showResultDialog.value = true;
}
async function handleSelectObject(obj: SchemaDiffObject) {
selectedObjectId.value = obj.id;
@@ -575,9 +757,9 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) {
function handleSaveConfig() {
if (activeConfig.value) {
const name = window.prompt(t("diff.saveConfigPrompt") || "Please enter config name:", activeConfig.value.name || "Default");
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
if (name === null) return; // User cancelled
const configToSave = { ...activeConfig.value, name: name.trim() || "Default" };
const configToSave = { ...activeConfig.value, name: name.trim() || t("diff.defaultConfigName") };
saveToHistory(configToSave);
toast(t("diff.configSaved"), 2000);
}
@@ -631,36 +813,21 @@ function handleDeployReview() {
}
async function handleDeploy() {
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 5000);
return;
}
showConfirmDialog.value = true;
}
async function onConfirmDeploy() {
showConfirmDialog.value = false;
executing.value = true;
try {
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
});
if (!result) return;
deployResult.value = {
success: true,
message: t("diff.deploySuccess"),
affectedRows: result.affected_rows,
};
showResultDialog.value = true;
} catch (e: any) {
deployResult.value = {
success: false,
message: e?.message || String(e),
};
showResultDialog.value = true;
} finally {
executing.value = false;
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 5000);
return;
}
await executeDeploySql();
}
const deployStats = computed(() => {
@@ -724,6 +891,8 @@ const targetConnectionInfo = computed(() => {
@save-config="handleSaveConfig"
@load-history-config="handleLoadHistoryConfig"
@delete-history-config="handleDeleteHistoryConfig"
@update:field-mappings="handleFieldMappingsUpdate"
@open-field-mapping="showFieldMappingDialog = true"
/>
<!-- Compare Loading -->
@@ -734,6 +903,49 @@ const targetConnectionInfo = computed(() => {
<!-- Result Step -->
<template v-else-if="step === 'result'">
<!-- Rename Candidates Panel -->
<div v-if="renameCandidates.length > 0" class="border-b bg-amber-50 dark:bg-amber-950/20 shrink-0 overflow-hidden">
<button class="flex items-center gap-2 px-3 py-1.5 w-full text-left text-xs font-medium hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors" @click="showRenamePanel = !showRenamePanel">
<ChevronDown v-if="showRenamePanel" class="w-3 h-3" />
<ChevronRight v-else class="w-3 h-3" />
{{ t("diff.renameCandidates") }}
<span class="ml-auto text-muted-foreground font-normal">{{ renameCandidates.length }} candidate(s)</span>
</button>
<div v-if="showRenamePanel" class="px-3 pb-2 text-xs">
<table class="w-full">
<thead>
<tr class="text-muted-foreground border-b">
<th class="text-left py-1 pr-4">{{ t("diff.sourceTable") }}</th>
<th class="text-left py-1 pr-4">{{ t("diff.targetTable") }}</th>
<th class="text-left py-1 pr-4">{{ t("diff.similarity") }}</th>
<th class="text-left py-1">{{ t("common.action") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(rc, i) in renameCandidates" :key="i" class="border-b border-amber-200/50 dark:border-amber-800/30">
<td class="py-1 pr-4 font-mono">{{ rc.sourceName }}</td>
<td class="py-1 pr-4 font-mono">{{ rc.targetName }}</td>
<td class="py-1 pr-4">
<span
class="px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="rc.score >= 0.8 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : rc.score >= 0.5 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'"
>{{ (rc.score * 100).toFixed(0) }}%</span
>
</td>
<td class="py-1">
<button class="text-xs px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors mr-1" @click="applyRename(rc)">
{{ t("diff.confirmRename") }}
</button>
<button class="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 transition-colors" @click="ignoreRename(i)">
{{ t("diff.ignore") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Splitpanes horizontal class="flex-1 min-h-0" @resized="handleSplitpanesResized">
<Pane :size="splitpanesSize" min-size="20">
<div class="h-full overflow-auto">
@@ -750,14 +962,45 @@ const targetConnectionInfo = computed(() => {
</div>
</Pane>
<Pane :size="100 - splitpanesSize" min-size="20">
<SchemaDiffDdlPanel :selected-object="selectedObject" :deploy-sql="deploySql" :deploy-sql-all="deploySqlAll" @execute-script="handleExecuteScript" />
<SchemaDiffDdlPanel
:selected-object="selectedObject"
:deploy-sql="deploySql"
:deploy-sql-all="deploySqlAll"
:compatibility-warnings="compatibilityWarnings"
:rollback-sql="rollbackSql"
:deploy-sql-mode="deploySqlMode"
:dependency-graph="dependencyGraph"
:permission-diffs="permissionDiffs"
:rollback-completeness="rollbackCompleteness"
:missing-rollback-objects="missingRollbackObjects"
:can-execute="canExecuteDeploy"
@update:deploy-sql-mode="switchDeploySqlMode"
@execute-script="handleExecuteScript"
/>
</Pane>
</Splitpanes>
</template>
<!-- Deploy Review Step -->
<template v-else-if="step === 'deploy-review'">
<SchemaDiffDeployStep v-model:deploy-sql="deploySql" :selected-objects="diffObjects" :target-connection-id="targetConnectionId" :target-database="targetDatabase" :target-schema="targetSchema" :executing="executing" @back="step = 'result'" @deploy="handleDeploy" />
<SchemaDiffDeployStep
v-model:deploy-sql="deploySql"
:selected-objects="diffObjects"
:target-connection-id="targetConnectionId"
:target-database="targetDatabase"
:target-schema="targetSchema"
:executing="executing"
:rollback-sql="rollbackSql"
:deploy-sql-mode="deploySqlMode"
:compatibility-warnings="compatibilityWarnings"
:rename-candidates="renameCandidates"
:rollback-completeness="rollbackCompleteness"
:missing-rollback-objects="missingRollbackObjects"
:can-execute="canExecuteDeploy"
@update:deploy-sql-mode="switchDeploySqlMode"
@back="step = 'result'"
@deploy="handleDeploy"
/>
</template>
</div>
@@ -837,12 +1080,23 @@ const targetConnectionInfo = computed(() => {
<DialogTitle class="flex items-center gap-2" :class="deployResult?.success ? 'text-green-500' : 'text-destructive'">
<AlertTriangle v-if="!deployResult?.success" class="h-5 w-5" />
<CircleCheck v-else class="h-5 w-5" />
{{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}
<template v-if="deployResult?.status === 'mixed'">{{ t("diff.deployMixedTitle") }}</template>
<template v-else-if="deployResult?.status === 'rolled_back'">{{ t("diff.deployRolledBackTitle") }}</template>
<template v-else>{{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}</template>
</DialogTitle>
</DialogHeader>
<div class="py-2">
<div v-if="deployResult?.success" class="space-y-2">
<div v-if="deployResult?.status === 'mixed'" class="space-y-2">
<p class="text-sm text-destructive-foreground">{{ deployResult.message }}</p>
<div class="bg-yellow-50 border border-yellow-300 p-3 rounded text-xs text-yellow-800">
{{ t("diff.deployMixedWarning") }}
</div>
</div>
<div v-else-if="deployResult?.status === 'rolled_back'" class="space-y-2">
<p class="text-sm text-destructive-foreground">{{ deployResult.message }}</p>
</div>
<div v-else-if="deployResult?.success" class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t("diff.deploySuccessMessage") }}</p>
<div class="bg-muted p-3 rounded text-xs font-mono">
<div>{{ t("diff.affectedRows") }}: {{ deployResult.affectedRows ?? 0 }}</div>
@@ -875,11 +1129,25 @@ const targetConnectionInfo = computed(() => {
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
<Button variant="ghost" size="sm" @click="showOptionsPanel = false">✕</Button>
<Button variant="ghost" size="sm" @click="showOptionsPanel = false" :aria-label="t('common.close')">✕</Button>
</div>
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
</div>
</div>
<!-- Field Mapping Dialog Overlay -->
<FieldMappingDialog
:open="showFieldMappingDialog"
:mappings="activeConfig?.options.fieldMappings ?? []"
:source-db-type="sourceDbType"
:target-db-type="targetDbType"
:source-connection-id="sourceConnectionId"
:source-database="sourceDatabase"
:target-connection-id="targetConnectionId"
:target-database="targetDatabase"
@update:open="showFieldMappingDialog = $event"
@save="handleFieldMappingsUpdate"
/>
</DialogContent>
</Dialog>
</template>
@@ -147,6 +147,36 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
</SelectContent>
</Select>
</div>
<!-- Advanced options -->
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.advancedSection") }}</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
<div v-if="localOptions.detectRenames || localOptions.detectTableRenames" class="flex items-center gap-2 pt-1">
<input id="schema-diff-rename-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.renameThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
<span class="text-xs font-mono w-10 text-right">{{ localOptions.renameThreshold.toFixed(2) }}</span>
</div>
<div v-else class="pt-1 text-xs text-muted-foreground italic">
{{ t("schemaDiff.options.detectRenames") }}
</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
<div class="flex items-center gap-2 pt-1">
<input id="schema-diff-compat-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.compatibilityThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
<span class="text-xs font-mono w-10 text-right">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.batchSection") }}</div>
<div class="flex items-center gap-2 col-span-2 pt-1">
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
</div>
</div>
</div>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { AlertTriangle, ShieldAlert } from "@lucide/vue";
const { t } = useI18n();
interface TagViolation {
fileName: string;
lineNumber: number;
tagName: string;
suggestion: string;
}
const props = defineProps<{
violations: TagViolation[];
blocking: boolean;
}>();
const emit = defineEmits<{
registerTag: [tagName: string];
removeTag: [tagName: string];
ignore: [tagName: string];
}>();
</script>
<template>
<div class="border rounded-md bg-card">
<div class="flex items-center gap-2 px-3 py-2 border-b">
<ShieldAlert class="w-4 h-4 text-amber-500" />
<h3 class="text-sm font-medium">{{ t("strictTag.title") }}</h3>
</div>
<div v-if="blocking" class="mx-3 mt-3 flex items-center gap-2 px-3 py-2 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-900/30">
<AlertTriangle class="w-3.5 h-3.5 shrink-0" />
<span>{{ t("strictTag.blockingExecution") }}</span>
</div>
<div v-if="violations.length === 0" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
{{ t("strictTag.noViolations") }}
</div>
<template v-else>
<div class="px-3 pt-3 pb-1 text-xs text-muted-foreground">
{{ t("strictTag.violationsFound", { count: violations.length }) }}
</div>
<div class="p-3">
<div class="border rounded overflow-hidden">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.fileName") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.lineNumber") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.tagName") }}</th>
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.suggestion") }}</th>
<th class="text-right px-2 py-1.5 font-medium text-muted-foreground">{{ t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in violations" :key="`${v.fileName}:${v.lineNumber}:${v.tagName}`" class="border-t">
<td class="px-2 py-1.5 font-mono">{{ v.fileName }}</td>
<td class="px-2 py-1.5">{{ v.lineNumber }}</td>
<td class="px-2 py-1.5 font-mono">{{ v.tagName }}</td>
<td class="px-2 py-1.5 text-muted-foreground">{{ v.suggestion }}</td>
<td class="px-2 py-1.5 text-right">
<div class="flex items-center justify-end gap-1">
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors" @click="emit('registerTag', v.tagName)">
{{ t("strictTag.registerTag") }}
</button>
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors" @click="emit('removeTag', v.tagName)">
{{ t("strictTag.removeTag") }}
</button>
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground hover:bg-muted/60 transition-colors" @click="emit('ignore', v.tagName)">
{{ t("strictTag.ignore") }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<style scoped></style>