Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1
This commit is contained in:
commit
40f0f98540
71 files changed
+6708
-1390
No files matched your search
@@ -359,8 +359,6 @@ const driverProfiles: Record<
|
||||
},
|
||||
sqlserver: { type: "sqlserver", port: 1433, user: "sa", label: "SQL Server", icon: "sqlserver" },
|
||||
oracle: { type: "oracle", port: 1521, user: "system", label: "Oracle", icon: "oracle" },
|
||||
"oracle-legacy": { type: "oracle", port: 1521, user: "system", label: "Oracle Legacy", icon: "oracle" },
|
||||
"oracle-10g": { type: "oracle", port: 1521, user: "system", label: "Oracle 10g", icon: "oracle" },
|
||||
elasticsearch: {
|
||||
type: "elasticsearch",
|
||||
port: 9200,
|
||||
@@ -474,6 +472,7 @@ const driverProfiles: Record<
|
||||
};
|
||||
|
||||
function profileForConfig(config: ConnectionConfig) {
|
||||
if (config.db_type === "oracle") return "oracle";
|
||||
if (config.driver_profile && driverProfiles[config.driver_profile]) {
|
||||
if (config.driver_profile === "oceanbase-oracle") return "oceanbase";
|
||||
return config.driver_profile;
|
||||
@@ -707,8 +706,6 @@ const iconTypeMap: Record<string, string> = {
|
||||
clickhouse: "clickhouse",
|
||||
sqlserver: "sqlserver",
|
||||
oracle: "oracle",
|
||||
"oracle-legacy": "oracle",
|
||||
"oracle-10g": "oracle",
|
||||
elasticsearch: "elasticsearch",
|
||||
mariadb: "mariadb",
|
||||
tidb: "tidb",
|
||||
@@ -2600,20 +2597,6 @@ function openExternalUrl(url: string) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.version") }}</Label>
|
||||
<Select :model-value="selectedType === 'oracle-legacy' || selectedType === 'oracle-10g' ? selectedType : 'oracle'" @update:model-value="(val) => applyProfile(String(val), true)">
|
||||
<SelectTrigger class="col-span-3 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="oracle">Oracle 19c+</SelectItem>
|
||||
<SelectItem value="oracle-legacy">Oracle 11g-19c</SelectItem>
|
||||
<SelectItem value="oracle-10g">Oracle 10g</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">SYSDBA</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { diffLines, type Change } from "diff";
|
||||
|
||||
export type HunkType = "equal" | "delete" | "insert" | "modify";
|
||||
|
||||
export interface DiffLine {
|
||||
type: HunkType;
|
||||
content: string;
|
||||
lineNumber: number | null;
|
||||
isPadding: boolean;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
id: string;
|
||||
type: HunkType;
|
||||
leftLines: DiffLine[];
|
||||
rightLines: DiffLine[];
|
||||
// Measured pixel positions after rendering
|
||||
leftTop: number;
|
||||
leftBottom: number;
|
||||
rightTop: number;
|
||||
rightBottom: number;
|
||||
}
|
||||
|
||||
const SIMILARITY_THRESHOLD = 0.3;
|
||||
const ALIGN_WINDOW = 3;
|
||||
|
||||
function splitLines(value: string): string[] {
|
||||
const lines = value.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildLines(lines: string[], type: HunkType, startLineNum: number): DiffLine[] {
|
||||
return lines.map((content, idx) => ({
|
||||
type,
|
||||
content,
|
||||
lineNumber: startLineNum + idx,
|
||||
isPadding: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPaddingLines(count: number): DiffLine[] {
|
||||
return Array.from({ length: count }, () => ({
|
||||
type: "equal" as HunkType,
|
||||
content: "",
|
||||
lineNumber: null,
|
||||
isPadding: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeDdl(ddl: string): string {
|
||||
return ddl
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+$/g, ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function collectSameKindChanges(changes: Change[], startIdx: number, kind: "added" | "removed"): [string[], number] {
|
||||
const parts: string[] = [];
|
||||
let i = startIdx;
|
||||
while (i < changes.length && changes[i][kind]) {
|
||||
parts.push(changes[i].value);
|
||||
i++;
|
||||
}
|
||||
return [parts, i];
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = [];
|
||||
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
||||
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
matrix[i][j] = b.charAt(i - 1) === a.charAt(j - 1) ? matrix[i - 1][j - 1] : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
||||
}
|
||||
}
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
function computeSimilarity(a: string, b: string): number {
|
||||
if (a === b) return 1;
|
||||
const longer = a.length > b.length ? a : b;
|
||||
if (longer.length === 0) return 1;
|
||||
const distance = levenshteinDistance(a, b);
|
||||
return (longer.length - distance) / longer.length;
|
||||
}
|
||||
|
||||
type AlignedItem = { type: "modify"; left: string; right: string } | { type: "delete"; left: string } | { type: "insert"; right: string };
|
||||
|
||||
function alignLineByLine(removedLines: string[], addedLines: string[]): AlignedItem[] {
|
||||
const result: AlignedItem[] = [];
|
||||
let r = 0;
|
||||
let a = 0;
|
||||
|
||||
while (r < removedLines.length || a < addedLines.length) {
|
||||
if (r < removedLines.length && a < addedLines.length) {
|
||||
const directSim = computeSimilarity(removedLines[r], addedLines[a]);
|
||||
if (directSim >= SIMILARITY_THRESHOLD) {
|
||||
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
|
||||
r++;
|
||||
a++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look ahead for best match within a small window
|
||||
let bestR = -1;
|
||||
let bestA = -1;
|
||||
let bestSim = SIMILARITY_THRESHOLD;
|
||||
|
||||
for (let ri = r; ri < Math.min(r + ALIGN_WINDOW, removedLines.length); ri++) {
|
||||
for (let aj = a; aj < Math.min(a + ALIGN_WINDOW, addedLines.length); aj++) {
|
||||
const sim = computeSimilarity(removedLines[ri], addedLines[aj]);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestR = ri;
|
||||
bestA = aj;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestR >= 0 && bestA >= 0) {
|
||||
while (r < bestR) {
|
||||
result.push({ type: "delete", left: removedLines[r] });
|
||||
r++;
|
||||
}
|
||||
while (a < bestA) {
|
||||
result.push({ type: "insert", right: addedLines[a] });
|
||||
a++;
|
||||
}
|
||||
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
|
||||
r++;
|
||||
a++;
|
||||
} else {
|
||||
result.push({ type: "delete", left: removedLines[r] });
|
||||
result.push({ type: "insert", right: addedLines[a] });
|
||||
r++;
|
||||
a++;
|
||||
}
|
||||
} else if (r < removedLines.length) {
|
||||
result.push({ type: "delete", left: removedLines[r] });
|
||||
r++;
|
||||
} else {
|
||||
result.push({ type: "insert", right: addedLines[a] });
|
||||
a++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildHunks(sourceDdl: string, targetDdl: string): DiffHunk[] {
|
||||
const normalizedSource = normalizeDdl(sourceDdl);
|
||||
const normalizedTarget = normalizeDdl(targetDdl);
|
||||
const changes = diffLines(normalizedSource, normalizedTarget, { newlineIsToken: false });
|
||||
|
||||
const hunks: DiffHunk[] = [];
|
||||
let leftLineNum = 1;
|
||||
let rightLineNum = 1;
|
||||
let hunkIdCounter = 0;
|
||||
|
||||
function nextId(): string {
|
||||
return `hunk-${hunkIdCounter++}`;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < changes.length) {
|
||||
const change = changes[i];
|
||||
|
||||
if (!change.added && !change.removed) {
|
||||
const lines = splitLines(change.value);
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "equal",
|
||||
leftLines: buildLines(lines, "equal", leftLineNum),
|
||||
rightLines: buildLines(lines, "equal", rightLineNum),
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
leftLineNum += lines.length;
|
||||
rightLineNum += lines.length;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
const [removedParts, afterRemoved] = collectSameKindChanges(changes, i, "removed");
|
||||
const [addedParts, afterAdded] = collectSameKindChanges(changes, afterRemoved, "added");
|
||||
const removedValue = removedParts.join("");
|
||||
const removedLines = splitLines(removedValue);
|
||||
|
||||
if (addedParts.length > 0) {
|
||||
const addedValue = addedParts.join("");
|
||||
const addedLines = splitLines(addedValue);
|
||||
const aligned = alignLineByLine(removedLines, addedLines);
|
||||
|
||||
for (const item of aligned) {
|
||||
if (item.type === "modify") {
|
||||
const maxLines = 1;
|
||||
const leftReal = buildLines([item.left], "modify", leftLineNum);
|
||||
const rightReal = buildLines([item.right], "modify", rightLineNum);
|
||||
leftLineNum++;
|
||||
rightLineNum++;
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "modify",
|
||||
leftLines: padLines(leftReal, maxLines, "modify"),
|
||||
rightLines: padLines(rightReal, maxLines, "modify"),
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
} else if (item.type === "delete") {
|
||||
const leftReal = buildLines([item.left], "delete", leftLineNum);
|
||||
leftLineNum++;
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "delete",
|
||||
leftLines: leftReal,
|
||||
rightLines: buildPaddingLines(1),
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
} else if (item.type === "insert") {
|
||||
const rightReal = buildLines([item.right], "insert", rightLineNum);
|
||||
rightLineNum++;
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "insert",
|
||||
leftLines: buildPaddingLines(1),
|
||||
rightLines: rightReal,
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const maxLines = removedLines.length;
|
||||
const leftReal = buildLines(removedLines, "delete", leftLineNum);
|
||||
leftLineNum += removedLines.length;
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "delete",
|
||||
leftLines: leftReal,
|
||||
rightLines: buildPaddingLines(maxLines),
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
}
|
||||
i = afterAdded;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.added) {
|
||||
const [addedParts, afterAdded] = collectSameKindChanges(changes, i, "added");
|
||||
const addedValue = addedParts.join("");
|
||||
const addedLines = splitLines(addedValue);
|
||||
const rightReal = buildLines(addedLines, "insert", rightLineNum);
|
||||
rightLineNum += addedLines.length;
|
||||
hunks.push({
|
||||
id: nextId(),
|
||||
type: "insert",
|
||||
leftLines: buildPaddingLines(addedLines.length),
|
||||
rightLines: rightReal,
|
||||
leftTop: 0,
|
||||
leftBottom: 0,
|
||||
rightTop: 0,
|
||||
rightBottom: 0,
|
||||
});
|
||||
i = afterAdded;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
function padLines(lines: DiffLine[], targetCount: number, type: HunkType): DiffLine[] {
|
||||
if (lines.length >= targetCount) return lines;
|
||||
const padding = Array.from({ length: targetCount - lines.length }, () => ({
|
||||
type,
|
||||
content: "",
|
||||
lineNumber: null,
|
||||
isPadding: true,
|
||||
}));
|
||||
return [...lines, ...padding];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DiffHunk } from "@/components/diff/DiffHunkBuilder";
|
||||
|
||||
const props = defineProps<{
|
||||
hunks: DiffHunk[];
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
}>();
|
||||
|
||||
const connectionPaths = computed(() => {
|
||||
const paths: { d: string; stroke: string; id: string }[] = [];
|
||||
const midX = props.containerWidth / 2;
|
||||
const halfLine = 10;
|
||||
|
||||
for (const hunk of props.hunks) {
|
||||
// Only draw short connectors for modify hunks
|
||||
if (hunk.type !== "modify") continue;
|
||||
if (hunk.leftBottom <= hunk.leftTop || hunk.rightBottom <= hunk.rightTop) continue;
|
||||
if (hunk.leftBottom < 0 || hunk.rightBottom < 0) continue;
|
||||
if (hunk.leftTop > props.containerHeight || hunk.rightTop > props.containerHeight) continue;
|
||||
|
||||
const leftY = (hunk.leftTop + hunk.leftBottom) / 2;
|
||||
const rightY = (hunk.rightTop + hunk.rightBottom) / 2;
|
||||
|
||||
paths.push({
|
||||
id: hunk.id,
|
||||
d: `M ${midX - halfLine},${leftY} L ${midX + halfLine},${rightY}`,
|
||||
stroke: "rgba(234, 179, 8, 0.6)",
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="absolute inset-0 pointer-events-none z-10" :width="containerWidth" :height="containerHeight" xmlns="http://www.w3.org/2000/svg">
|
||||
<path v-for="path in connectionPaths" :key="path.id" :d="path.d" :stroke="path.stroke" fill="none" stroke-width="2" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Download, Upload, Plus, Trash2, Copy, FileDown } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig } from "@/types/schemaDiff";
|
||||
|
||||
const props = defineProps<{
|
||||
configs: SchemaDiffConfig[];
|
||||
activeConfigId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:activeConfigId", id: string): void;
|
||||
(e: "create", name: string): void;
|
||||
(e: "rename", id: string, name: string): void;
|
||||
(e: "delete", id: string): void;
|
||||
(e: "duplicate", id: string): void;
|
||||
(e: "export", config: SchemaDiffConfig): void;
|
||||
(e: "exportAll", configs: SchemaDiffConfig[]): void;
|
||||
(e: "import", jsonText: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const renameDialogOpen = ref(false);
|
||||
const renameValue = ref("");
|
||||
const renamingId = ref("");
|
||||
|
||||
const importDialogOpen = ref(false);
|
||||
const importValue = ref("");
|
||||
const importError = ref("");
|
||||
|
||||
function onSelectChange(value: unknown) {
|
||||
emit("update:activeConfigId", String(value));
|
||||
}
|
||||
|
||||
function startRename(config: SchemaDiffConfig) {
|
||||
renamingId.value = config.id;
|
||||
renameValue.value = config.name;
|
||||
renameDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (renamingId.value && renameValue.value.trim()) {
|
||||
emit("rename", renamingId.value, renameValue.value.trim());
|
||||
}
|
||||
renameDialogOpen.value = false;
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const baseName = t("schemaDiff.newConfigName");
|
||||
let name = baseName;
|
||||
let counter = 1;
|
||||
while (props.configs.some((c) => c.name === name)) {
|
||||
counter++;
|
||||
name = `${baseName} ${counter}`;
|
||||
}
|
||||
emit("create", name);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
importError.value = "";
|
||||
try {
|
||||
JSON.parse(importValue.value);
|
||||
emit("import", importValue.value);
|
||||
importDialogOpen.value = false;
|
||||
importValue.value = "";
|
||||
} catch {
|
||||
importError.value = t("schemaDiff.importInvalidJson");
|
||||
}
|
||||
}
|
||||
|
||||
function downloadJson(data: string, filename: string) {
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function onExport() {
|
||||
const config = props.configs.find((c) => c.id === props.activeConfigId);
|
||||
if (config) {
|
||||
emit("export", config);
|
||||
downloadJson(JSON.stringify(config, null, 2), `dbx-schema-diff-${config.name}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
function onExportAll() {
|
||||
emit("exportAll", props.configs);
|
||||
downloadJson(JSON.stringify(props.configs, null, 2), "dbx-schema-diff-configs.json");
|
||||
}
|
||||
|
||||
async function onImportFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
JSON.parse(text);
|
||||
emit("import", text);
|
||||
importDialogOpen.value = false;
|
||||
} catch {
|
||||
importError.value = t("schemaDiff.importInvalidJson");
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
const activeConfig = computed(() => props.configs.find((c) => c.id === props.activeConfigId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="activeConfigId" @update:model-value="onSelectChange">
|
||||
<SelectTrigger class="h-8 text-sm min-w-[180px]">
|
||||
<SelectValue :placeholder="t('schemaDiff.selectConfig')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="config in configs" :key="config.id" :value="config.id">
|
||||
{{ config.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.newConfig')" @click="handleCreate">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.renameConfig')" @click="startRename(activeConfig)">
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.duplicateConfig')" @click="emit('duplicate', activeConfig.id)">
|
||||
<FileDown class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="activeConfig && configs.length > 1" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.deleteConfig')" @click="activeConfig && emit('delete', activeConfig.id)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.exportConfig')" @click="onExport">
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.importConfig')" @click="importDialogOpen = true">
|
||||
<Upload class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.exportAllConfigs')" @click="onExportAll">
|
||||
<FileDown class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
<Dialog v-model:open="renameDialogOpen">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("schemaDiff.renameConfig") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="py-4">
|
||||
<Label class="text-sm">{{ t("schemaDiff.configName") }}</Label>
|
||||
<Input v-model="renameValue" class="mt-2" @keydown.enter="confirmRename" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="renameDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button @click="confirmRename">{{ t("common.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<Dialog v-model:open="importDialogOpen">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("schemaDiff.importConfig") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="py-4 space-y-4">
|
||||
<div>
|
||||
<Label class="text-sm">{{ t("schemaDiff.importFromFile") }}</Label>
|
||||
<Input type="file" accept=".json" class="mt-2" @change="onImportFile" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-sm">{{ t("schemaDiff.importFromText") }}</Label>
|
||||
<textarea v-model="importValue" class="mt-2 w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm" :placeholder="t('schemaDiff.importPlaceholder')" />
|
||||
</div>
|
||||
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="importDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button @click="handleImport">{{ t("common.import") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
configs: SchemaDiffConfig[];
|
||||
activeConfigId: string;
|
||||
sourceConnectionId: string;
|
||||
sourceDatabase: string;
|
||||
sourceSchema: string;
|
||||
targetConnectionId: string;
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
ignoreComments: boolean;
|
||||
options: SchemaDiffCompareOptions;
|
||||
loading: boolean;
|
||||
recentConfigs: SchemaDiffConfig[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:sourceConnectionId", value: string): void;
|
||||
(e: "update:sourceDatabase", value: string): void;
|
||||
(e: "update:sourceSchema", value: string): void;
|
||||
(e: "update:targetConnectionId", value: string): void;
|
||||
(e: "update:targetDatabase", value: string): void;
|
||||
(e: "update:targetSchema", value: string): void;
|
||||
(e: "update:ignoreComments", value: boolean): void;
|
||||
(e: "compare"): void;
|
||||
(e: "saveConfig"): void;
|
||||
(e: "loadConfig"): void;
|
||||
(e: "showOptions"): void;
|
||||
(e: "swap"): void;
|
||||
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
|
||||
(e: "deleteHistoryConfig", configId: string): void;
|
||||
}>();
|
||||
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
const sourceSchemas = ref<string[]>([]);
|
||||
const targetDatabases = ref<string[]>([]);
|
||||
const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => c.db_type !== "mongodb" && c.db_type !== "redis"));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const dbs = await api.listDatabases(connectionId);
|
||||
const dbNames = Array.isArray(dbs) ? dbs.map((db: any) => (typeof db === "string" ? db : db.name || db.database)) : [];
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = dbNames;
|
||||
if (props.sourceDatabase) {
|
||||
await fetchDbVersion(connectionId, props.sourceDatabase, props.sourceSchema, "source");
|
||||
// Ensure schema list is loaded after databases are available (handles race with sourceDatabase watcher)
|
||||
if (isSchemaAware(sourceConfig.value?.db_type)) {
|
||||
await loadSchemas("source");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetDatabases.value = dbNames;
|
||||
if (props.targetDatabase) {
|
||||
await fetchDbVersion(connectionId, props.targetDatabase, props.targetSchema, "target");
|
||||
// Ensure schema list is loaded after databases are available (handles race with targetDatabase watcher)
|
||||
if (isSchemaAware(targetConfig.value?.db_type)) {
|
||||
await loadSchemas("target");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = [];
|
||||
sourceDbVersion.value = null;
|
||||
} else {
|
||||
targetDatabases.value = [];
|
||||
targetDbVersion.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchemas(side: "source" | "target") {
|
||||
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
||||
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
||||
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
||||
if (!connectionId || !database) return;
|
||||
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = schemas;
|
||||
} else {
|
||||
targetSchemas.value = schemas;
|
||||
}
|
||||
await fetchDbVersion(connectionId, database, schema, side);
|
||||
} catch {
|
||||
if (side === "source") {
|
||||
sourceSchemas.value = [];
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sourceConnectionId,
|
||||
async (id) => {
|
||||
if (id) {
|
||||
await loadDatabases(id, "source");
|
||||
} else {
|
||||
sourceDatabases.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sourceDatabase,
|
||||
async (db) => {
|
||||
if (db && props.sourceConnectionId) {
|
||||
await loadSchemas("source");
|
||||
} else {
|
||||
sourceSchemas.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.targetConnectionId,
|
||||
async (id) => {
|
||||
if (id) {
|
||||
await loadDatabases(id, "target");
|
||||
} else {
|
||||
targetDatabases.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.targetDatabase,
|
||||
async (db) => {
|
||||
if (db && props.targetConnectionId) {
|
||||
await loadSchemas("target");
|
||||
} else {
|
||||
targetSchemas.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function connectionIconType(connectionId: string) {
|
||||
const c = store.getConfig(connectionId);
|
||||
return c?.driver_profile || c?.db_type || "mysql";
|
||||
}
|
||||
|
||||
function getConnectionInfo(connectionId: string) {
|
||||
const c = store.getConfig(connectionId);
|
||||
if (!c) return null;
|
||||
return {
|
||||
name: c.name,
|
||||
dbType: c.db_type,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDbVersion(connectionId: string, database: string, schema: string, side: "source" | "target") {
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
const dbType = config?.db_type;
|
||||
let sql = "";
|
||||
switch (dbType) {
|
||||
case "postgres":
|
||||
case "opengauss":
|
||||
sql = "SELECT version()";
|
||||
break;
|
||||
case "mysql":
|
||||
sql = "SELECT VERSION()";
|
||||
break;
|
||||
case "sqlite":
|
||||
sql = "SELECT sqlite_version()";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
const version = String(result.rows[0][0]);
|
||||
if (side === "source") {
|
||||
sourceDbVersion.value = version;
|
||||
} else {
|
||||
targetDbVersion.value = version;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[fetchDbVersion] Failed to fetch version for ${side}:`, e);
|
||||
if (side === "source") {
|
||||
sourceDbVersion.value = null;
|
||||
} else {
|
||||
targetDbVersion.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-center gap-4 py-2">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-muted-foreground">{{ sourceConfig?.name || t("diff.source") }}</div>
|
||||
<div class="text-xs font-medium">{{ sourceDatabase }}{{ sourceSchema ? `.${sourceSchema}` : "" }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-5 h-5" />
|
||||
<ArrowLeftRight class="w-4 h-4 text-muted-foreground" />
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-muted-foreground">{{ targetConfig?.name || t("diff.target") }}</div>
|
||||
<div class="text-xs font-medium">{{ targetDatabase }}{{ targetSchema ? `.${targetSchema}` : "" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source / Target Selection -->
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<!-- Source Side -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-blue-500">{{ t("diff.source") }}</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => $emit('update:sourceConnectionId', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
||||
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => $emit('update:sourceDatabase', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!sourceDatabases.length">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="isSchemaAware(sourceConfig?.db_type)" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
||||
<Select :model-value="sourceSchema" @update:model-value="(v: any) => $emit('update:sourceSchema', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!sourceSchemas.length">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Source Info -->
|
||||
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
|
||||
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
||||
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.dbType }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.name }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.host }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
||||
<span>{{ getConnectionInfo(sourceConnectionId)?.port }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
||||
<span>{{ sourceDbVersion || "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swap Button -->
|
||||
<div class="pt-8">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('swap')">
|
||||
<ArrowLeftRight class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Target Side -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-green-500">{{ t("diff.target") }}</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => $emit('update:targetConnectionId', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
||||
<Select :model-value="targetDatabase" @update:model-value="(v: any) => $emit('update:targetDatabase', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!targetDatabases.length">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="isSchemaAware(targetConfig?.db_type)" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
||||
<Select :model-value="targetSchema" @update:model-value="(v: any) => $emit('update:targetSchema', String(v))">
|
||||
<SelectTrigger class="h-8 text-xs" :disabled="!targetSchemas.length">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Target Info -->
|
||||
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
|
||||
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
||||
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.dbType }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.name }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.host }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
||||
<span>{{ getConnectionInfo(targetConnectionId)?.port }}</span>
|
||||
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
||||
<span>{{ targetDbVersion || "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border bg-muted/20">
|
||||
<input id="schema-diff-ignore-comments" :checked="ignoreComments" type="checkbox" class="accent-primary" @change="$emit('update:ignoreComments', ($event.target as HTMLInputElement).checked)" />
|
||||
<Label for="schema-diff-ignore-comments" class="cursor-pointer text-xs">
|
||||
{{ t("diff.ignoreComments") }}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Recent Configs Dropdown -->
|
||||
<div v-if="recentConfigs.length > 0" class="flex items-center gap-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("diff.recentConfigs") }}</Label>
|
||||
<Select
|
||||
:model-value="''"
|
||||
@update:model-value="
|
||||
(v: any) => {
|
||||
const config = recentConfigs.find((c) => c.id === v);
|
||||
if (config) $emit('loadHistoryConfig', config);
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-[280px]">
|
||||
<SelectValue :placeholder="t('diff.selectRecentConfig')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="config in recentConfigs" :key="config.id" :value="config.id" class="pr-8">
|
||||
<div class="flex items-center justify-between w-full gap-2">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-xs font-medium truncate">{{ config.name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground truncate">
|
||||
{{ store.getConfig(config.sourceConnectionId)?.name || config.sourceConnectionId }}
|
||||
/{{ config.sourceDatabase }}{{ config.sourceSchema ? `.${config.sourceSchema}` : "" }}
|
||||
→
|
||||
{{ store.getConfig(config.targetConnectionId)?.name || config.targetConnectionId }}
|
||||
/{{ config.targetDatabase }}{{ config.targetSchema ? `.${config.targetSchema}` : "" }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors" @click.stop="$emit('deleteHistoryConfig', config.id)">
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="$emit('saveConfig')">
|
||||
<Save class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.saveConfig") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="$emit('loadConfig')">
|
||||
<FolderOpen class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.loadConfig") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="$emit('showOptions')">
|
||||
<Settings class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.options") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
|
||||
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
||||
{{ loading ? t("common.loading") : t("diff.compare") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
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 DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
|
||||
import { FileCode, ScrollText, Copy, Play } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import type { SchemaDiffObject } from "@/lib/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const ddlColors = computed(() => {
|
||||
const themes = settingsStore.editorSettings.customThemes;
|
||||
const activeId = settingsStore.editorSettings.activeCustomThemeId;
|
||||
const activeTheme = themes.find((t) => t.id === activeId);
|
||||
return activeTheme?.ddlColors ?? DEFAULT_CUSTOM_THEME_DDL_COLORS;
|
||||
});
|
||||
|
||||
function toRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha / 100})`;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selectedObject: SchemaDiffObject | null;
|
||||
deploySql: string;
|
||||
deploySqlAll: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
executeScript: [];
|
||||
}>();
|
||||
|
||||
const activeTab = ref<"ddl" | "script" | "scriptAll">("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 hunks = computed(() => {
|
||||
if (!props.selectedObject?.sourceDdl && !props.selectedObject?.targetDdl) return [];
|
||||
return buildHunks(props.selectedObject?.sourceDdl || "", props.selectedObject?.targetDdl || "");
|
||||
});
|
||||
|
||||
const { syncScroll, measureHunks } = useDiffScrollSync({
|
||||
container: diffContainerRef,
|
||||
leftPane: leftPaneRef,
|
||||
rightPane: rightPaneRef,
|
||||
hunks,
|
||||
});
|
||||
|
||||
// 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[] }>();
|
||||
for (const hunk of hunks.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;
|
||||
|
||||
function requestMeasure() {
|
||||
if (measureRaf) return;
|
||||
measureRaf = requestAnimationFrame(() => {
|
||||
measureRaf = null;
|
||||
measureHunks();
|
||||
connectorKey.value++;
|
||||
});
|
||||
}
|
||||
|
||||
function requestMeasureDebounced() {
|
||||
if (measureTimeout) clearTimeout(measureTimeout);
|
||||
measureTimeout = setTimeout(() => {
|
||||
requestMeasure();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleScroll(from: "left" | "right") {
|
||||
syncScroll(from);
|
||||
requestMeasureDebounced();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedObject?.id,
|
||||
async () => {
|
||||
await nextTick();
|
||||
updateContainerSize();
|
||||
requestMeasure();
|
||||
},
|
||||
);
|
||||
|
||||
function updateContainerSize() {
|
||||
const el = diffContainerRef.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
containerSize.value = { width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function onSplitpanesResized() {
|
||||
updateContainerSize();
|
||||
requestMeasure();
|
||||
}
|
||||
|
||||
function lineBackground(line: DiffLine): string | undefined {
|
||||
if (line.isPadding) return undefined;
|
||||
if (line.type === "delete") {
|
||||
// source-only = will be added to target = green
|
||||
return toRgba(ddlColors.value.addedRowBg, ddlColors.value.addedRowBgAlpha);
|
||||
}
|
||||
if (line.type === "insert") {
|
||||
// target-only = will be removed from target = red
|
||||
return toRgba(ddlColors.value.removedRowBg, ddlColors.value.removedRowBgAlpha);
|
||||
}
|
||||
if (line.type === "modify") {
|
||||
return toRgba(ddlColors.value.modifiedRowBg, ddlColors.value.modifiedRowBgAlpha);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lineTextClass(line: DiffLine): string {
|
||||
if (line.isPadding) return "text-transparent";
|
||||
if (line.type === "insert") return "line-through opacity-80";
|
||||
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 charDiffs = computeCharDiffs(leftLine.content, rightLine.content);
|
||||
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 { leftSegments, rightSegments };
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
function copyDeploySql() {
|
||||
copyToClipboard(props.deploySql);
|
||||
toast(t("diff.copied"), 2000);
|
||||
}
|
||||
|
||||
function copyDeploySqlAll() {
|
||||
copyToClipboard(props.deploySqlAll);
|
||||
toast(t("diff.copied"), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-md flex flex-col h-full">
|
||||
<!-- 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'">
|
||||
<FileCode class="w-3.5 h-3.5" />
|
||||
{{ t("diff.ddlCompare") }}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'script' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'script'">
|
||||
<ScrollText class="w-3.5 h-3.5" />
|
||||
{{ t("diff.deployScript") }}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'scriptAll' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'scriptAll'">
|
||||
<ScrollText class="w-3.5 h-3.5" />
|
||||
{{ t("diff.deployScriptAll") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DDL Compare -->
|
||||
<div v-if="activeTab === 'ddl'" class="flex-1 overflow-hidden relative">
|
||||
<!-- No object selected -->
|
||||
<div v-if="!selectedObject" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.selectObjectToCompare") }}
|
||||
</div>
|
||||
<!-- No DDL data available -->
|
||||
<div v-else-if="!selectedObject.sourceDdl && !selectedObject.targetDdl" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDdlAvailable") }}
|
||||
</div>
|
||||
<!-- Diff View -->
|
||||
<div v-else ref="diffContainerRef" class="absolute inset-0 font-mono text-xs leading-relaxed">
|
||||
<Splitpanes class="h-full" @resized="onSplitpanesResized">
|
||||
<!-- 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">
|
||||
{{ 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-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 modifySegments.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>
|
||||
|
||||
<!-- 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">
|
||||
{{ 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-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 modifySegments.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>
|
||||
|
||||
<!-- SVG Connector Overlay -->
|
||||
<DiffSvgConnector :key="connectorKey" :hunks="hunks" :container-width="containerSize.width" :container-height="containerSize.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">
|
||||
<span class="text-xs text-muted-foreground">{{ t("diff.deployScriptDesc") }}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="copyDeploySql">
|
||||
<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')">
|
||||
<Play class="w-3 h-3" />
|
||||
{{ t("diff.execute") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<pre class="text-xs whitespace-pre-wrap font-mono">{{ deploySql || t("diff.noDeployScript") }}</pre>
|
||||
</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">
|
||||
<span class="text-xs text-muted-foreground">{{ t("diff.deployScriptAllDesc") }}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="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')">
|
||||
<Play class="w-3 h-3" />
|
||||
{{ t("diff.executeAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<pre class="text-xs whitespace-pre-wrap font-mono">{{ deploySqlAll || t("diff.noDeployScriptAll") }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
background-color: hsl(var(--border));
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
}
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter:hover) {
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, shallowRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schemaDiff";
|
||||
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const props = defineProps<{
|
||||
deploySql: string;
|
||||
selectedObjects: SchemaDiffObject[];
|
||||
targetConnectionId: string;
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
executing: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:deploySql": [sql: string];
|
||||
back: [];
|
||||
deploy: [];
|
||||
}>();
|
||||
|
||||
const editorContainer = ref<HTMLDivElement>();
|
||||
const editorView = shallowRef<any>(null);
|
||||
const isEditorReady = ref(false);
|
||||
const selectedObjectId = ref<string | null>(null);
|
||||
|
||||
// Parse object positions in deploySql
|
||||
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}`];
|
||||
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+:/);
|
||||
if (nextMatch && nextMatch.index !== undefined) {
|
||||
endPos = index + pattern.length + nextMatch.index;
|
||||
}
|
||||
positions.set(obj.id, { from: index, to: endPos });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
});
|
||||
|
||||
async function handleSelectObject(obj: SchemaDiffObject) {
|
||||
selectedObjectId.value = obj.id;
|
||||
if (editorView.value) {
|
||||
const pos = objectPositions.value.get(obj.id);
|
||||
if (pos) {
|
||||
const { EditorView } = await import("@codemirror/view");
|
||||
editorView.value.dispatch({
|
||||
effects: EditorView.scrollIntoView(pos.from, { y: "start" }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter top-level selected objects (exclude children and none)
|
||||
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;
|
||||
})
|
||||
.sort((a, b) => operationOrder[a.operationType] - operationOrder[b.operationType]);
|
||||
});
|
||||
|
||||
const operationCounts = computed(() => {
|
||||
const counts: Record<DiffOperationType, number> = { create: 0, modify: 0, delete: 0, none: 0 };
|
||||
for (const obj of topLevelObjects.value) {
|
||||
counts[obj.operationType]++;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const operationIcons: Record<DiffOperationType, any> = {
|
||||
modify: ArrowRightLeft,
|
||||
create: PlusCircle,
|
||||
delete: XCircle,
|
||||
none: ArrowRightLeft,
|
||||
};
|
||||
|
||||
const operationColors: Record<DiffOperationType, string> = {
|
||||
modify: "text-blue-500",
|
||||
create: "text-green-500",
|
||||
delete: "text-red-500",
|
||||
none: "text-muted-foreground",
|
||||
};
|
||||
|
||||
// Initialize CodeMirror editor
|
||||
async function initEditor() {
|
||||
if (!editorContainer.value) return;
|
||||
|
||||
const [{ EditorView }, { EditorState, Compartment }, { sql, PostgreSQL, SQLDialect }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]);
|
||||
|
||||
const themeComp = new Compartment();
|
||||
const fontComp = new Compartment();
|
||||
|
||||
const editorTheme = settingsStore.editorSettings.theme;
|
||||
const appAppearance = isDark.value ? "dark" : "light";
|
||||
const fontSize = settingsStore.editorSettings.fontSize;
|
||||
const fontFamily = settingsStore.editorSettings.fontFamily;
|
||||
|
||||
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
|
||||
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
|
||||
|
||||
// Custom PostgreSQL dialect with PL/pgSQL support (same as QueryEditor.vue)
|
||||
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
||||
const plpgsqlKeywords = "PERFORM";
|
||||
const plpgsqlTypes = " RECORD JSON JSONB";
|
||||
const plpgsqlBuiltin = "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV";
|
||||
|
||||
const dialect = SQLDialect.define({
|
||||
...PostgreSQL.spec,
|
||||
keywords: [PostgreSQL.spec.keywords || "", extraKeywords, plpgsqlKeywords].filter(Boolean).join(" "),
|
||||
types: [PostgreSQL.spec.types || "", plpgsqlTypes].filter(Boolean).join(" ") || undefined,
|
||||
builtin: [PostgreSQL.spec.builtin || "", plpgsqlBuiltin].filter(Boolean).join(" ") || undefined,
|
||||
doubleDollarQuotedStrings: false,
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.deploySql,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
sql({ dialect }),
|
||||
themeComp.of(themeExt),
|
||||
fontComp.of(fontExt),
|
||||
EditorView.updateListener.of((update: any) => {
|
||||
if (update.docChanged) {
|
||||
emit("update:deploySql", update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
editorView.value = new EditorView({ state, parent: editorContainer.value });
|
||||
isEditorReady.value = true;
|
||||
}
|
||||
|
||||
// Watch for external deploySql changes
|
||||
watch(
|
||||
() => props.deploySql,
|
||||
(newVal) => {
|
||||
if (editorView.value && editorView.value.state.doc.toString() !== newVal) {
|
||||
editorView.value.dispatch({
|
||||
changes: { from: 0, to: editorView.value.state.doc.length, insert: newVal },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initEditor();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
editorView.value?.destroy();
|
||||
editorView.value = null;
|
||||
});
|
||||
|
||||
function handleCopy() {
|
||||
copyToClipboard(props.deploySql);
|
||||
toast(t("diff.copied"), 2000);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const dbName = props.targetDatabase || "deploy";
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const defaultName = `${dbName}_deploy_${timestamp}.sql`;
|
||||
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: "SQL", extensions: ["sql"] }],
|
||||
});
|
||||
|
||||
if (path) {
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
await writeTextFile(path, props.deploySql);
|
||||
toast(t("diff.exportSuccess"), 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeploy() {
|
||||
emit("deploy");
|
||||
}
|
||||
|
||||
function getOperationLabel(type: DiffOperationType): string {
|
||||
switch (type) {
|
||||
case "create":
|
||||
return t("diff.create");
|
||||
case "delete":
|
||||
return t("diff.delete");
|
||||
case "modify":
|
||||
return t("diff.modify");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getObjectIcon(kind: DiffObjectKind) {
|
||||
switch (kind) {
|
||||
case "table":
|
||||
return Table;
|
||||
case "view":
|
||||
return Eye;
|
||||
case "function":
|
||||
return FunctionSquare;
|
||||
case "sequence":
|
||||
return ListOrdered;
|
||||
case "rule":
|
||||
return ScrollText;
|
||||
case "owner":
|
||||
return UserCog;
|
||||
case "index":
|
||||
return ListTree;
|
||||
case "foreignKey":
|
||||
return Link2;
|
||||
case "trigger":
|
||||
return Zap;
|
||||
default:
|
||||
return Table;
|
||||
}
|
||||
}
|
||||
|
||||
function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
switch (kind) {
|
||||
case "table":
|
||||
return "text-amber-500";
|
||||
case "view":
|
||||
return "text-cyan-500";
|
||||
case "function":
|
||||
return "text-purple-500";
|
||||
case "sequence":
|
||||
return "text-orange-500";
|
||||
case "rule":
|
||||
return "text-pink-500";
|
||||
case "owner":
|
||||
return "text-indigo-500";
|
||||
case "index":
|
||||
return "text-teal-500";
|
||||
case "foreignKey":
|
||||
return "text-lime-500";
|
||||
case "trigger":
|
||||
return "text-rose-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="$emit('back')">
|
||||
<ArrowLeft class="w-3.5 h-3.5" />
|
||||
{{ t("diff.backToResult") }}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<Splitpanes class="flex-1 min-h-0">
|
||||
<Pane size="30" min-size="20">
|
||||
<div class="h-full overflow-auto p-2 space-y-0.5">
|
||||
<div v-for="obj in topLevelObjects" :key="obj.id" class="flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-accent/50 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="handleSelectObject(obj)">
|
||||
<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 class="text-[10px] text-muted-foreground shrink-0 ml-auto">
|
||||
{{ getOperationLabel(obj.operationType) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="topLevelObjects.length === 0" class="text-xs text-muted-foreground text-center py-4">
|
||||
{{ t("diff.noObjectsSelected") }}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<Pane size="70" min-size="40">
|
||||
<div ref="editorContainer" class="h-full w-full" />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<!-- 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">
|
||||
<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") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="handleExport">
|
||||
<Download class="w-3.5 h-3.5" />
|
||||
{{ t("diff.exportSql") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-7 text-xs gap-1" @click="$emit('back')">
|
||||
<ArrowLeft class="w-3.5 h-3.5" />
|
||||
{{ t("diff.backToResult") }}
|
||||
</Button>
|
||||
<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">
|
||||
<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") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
width: 4px;
|
||||
background: hsl(var(--border));
|
||||
cursor: col-resize;
|
||||
}
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter:hover) {
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schemaDiff";
|
||||
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap, 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 props = defineProps<{
|
||||
groups: OperationGroup[];
|
||||
selectedObjectId?: string | null;
|
||||
}>();
|
||||
|
||||
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: "selectObject", object: SchemaDiffObject): void;
|
||||
}>();
|
||||
|
||||
const operationIcons: Record<DiffOperationType, any> = {
|
||||
modify: ArrowRightLeft,
|
||||
create: PlusCircle,
|
||||
delete: XCircle,
|
||||
none: MinusCircle,
|
||||
};
|
||||
|
||||
const operationColors: Record<DiffOperationType, string> = {
|
||||
modify: "text-blue-500",
|
||||
create: "text-green-500",
|
||||
delete: "text-red-500",
|
||||
none: "text-muted-foreground",
|
||||
};
|
||||
|
||||
const operationBgColors: Record<DiffOperationType, string> = {
|
||||
modify: "bg-blue-500/10 border-blue-500/20",
|
||||
create: "bg-green-500/10 border-green-500/20",
|
||||
delete: "bg-red-500/10 border-red-500/20",
|
||||
none: "bg-muted/30 border-muted",
|
||||
};
|
||||
|
||||
function getObjectIcon(kind: DiffObjectKind) {
|
||||
switch (kind) {
|
||||
case "table":
|
||||
return Table;
|
||||
case "view":
|
||||
return Eye;
|
||||
case "function":
|
||||
return FunctionSquare;
|
||||
case "sequence":
|
||||
return ListOrdered;
|
||||
case "rule":
|
||||
return ScrollText;
|
||||
case "owner":
|
||||
return UserCog;
|
||||
case "index":
|
||||
return ListTree;
|
||||
case "foreignKey":
|
||||
return Link2;
|
||||
case "trigger":
|
||||
return Zap;
|
||||
default:
|
||||
return Table;
|
||||
}
|
||||
}
|
||||
|
||||
function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
switch (kind) {
|
||||
case "table":
|
||||
return "text-amber-500";
|
||||
case "view":
|
||||
return "text-cyan-500";
|
||||
case "function":
|
||||
return "text-purple-500";
|
||||
case "sequence":
|
||||
return "text-orange-500";
|
||||
case "rule":
|
||||
return "text-pink-500";
|
||||
case "owner":
|
||||
return "text-indigo-500";
|
||||
case "index":
|
||||
return "text-teal-500";
|
||||
case "foreignKey":
|
||||
return "text-lime-500";
|
||||
case "trigger":
|
||||
return "text-rose-500";
|
||||
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 "index":
|
||||
return "diff.objectKindLabel.index";
|
||||
case "foreignKey":
|
||||
return "diff.objectKindLabel.foreignKey";
|
||||
case "trigger":
|
||||
return "diff.objectKindLabel.trigger";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupFullySelected(group: OperationGroup): boolean {
|
||||
return group.count > 0 && group.selectedCount === group.count;
|
||||
}
|
||||
|
||||
function isGroupPartiallySelected(group: OperationGroup): boolean {
|
||||
return group.selectedCount > 0 && group.selectedCount < group.count;
|
||||
}
|
||||
|
||||
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 onGroupCheckboxChange(group: OperationGroup, event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
emit("toggleGroupSelection", group.operationType, 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(obj: SchemaDiffObject, event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
emit("toggleObjectSelection", obj.id, checked);
|
||||
}
|
||||
|
||||
function formatObjectName(obj: SchemaDiffObject): string {
|
||||
if (obj.objectKind === "function" && obj.arguments) {
|
||||
return `${obj.name}(${obj.arguments})`;
|
||||
}
|
||||
return obj.name;
|
||||
}
|
||||
</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 -->
|
||||
<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)" />
|
||||
|
||||
<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>
|
||||
</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" />
|
||||
|
||||
<input type="checkbox" class="accent-primary shrink-0" :checked="isTypeGroupFullySelected(typeGroup)" :indeterminate="isTypeGroupPartiallySelected(typeGroup)" @click.stop @change="onTypeCheckboxChange(group, typeGroup, $event)" />
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Operation -->
|
||||
<div class="flex justify-center">
|
||||
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5" :class="operationColors[obj.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.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffOptionItem } from "@/types/schemaDiff";
|
||||
|
||||
const props = defineProps<{
|
||||
options: SchemaDiffCompareOptions;
|
||||
optionTree: SchemaDiffOptionItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:options", options: SchemaDiffCompareOptions): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Local copy of options
|
||||
const localOptions = ref<SchemaDiffCompareOptions>({ ...props.options });
|
||||
|
||||
// Watch for external changes
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
localOptions.value = { ...newOptions };
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function isChecked(id: keyof SchemaDiffCompareOptions): boolean {
|
||||
return !!localOptions.value[id];
|
||||
}
|
||||
|
||||
function setOption(id: keyof SchemaDiffCompareOptions, checked: boolean) {
|
||||
localOptions.value = { ...localOptions.value, [id]: checked };
|
||||
}
|
||||
|
||||
function getChildState(item: SchemaDiffOptionItem): "checked" | "unchecked" | "indeterminate" {
|
||||
if (!item.children || item.children.length === 0) {
|
||||
return isChecked(item.id) ? "checked" : "unchecked";
|
||||
}
|
||||
const childStates = item.children.map((child) => getChildState(child));
|
||||
if (childStates.every((s) => s === "checked")) return "checked";
|
||||
if (childStates.every((s) => s === "unchecked")) return "unchecked";
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
function toggleItem(item: SchemaDiffOptionItem) {
|
||||
const state = getChildState(item);
|
||||
const nextChecked = state !== "checked";
|
||||
setSubtree(item, nextChecked);
|
||||
}
|
||||
|
||||
function setSubtree(item: SchemaDiffOptionItem, checked: boolean) {
|
||||
setOption(item.id, checked);
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
setSubtree(child, checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
emit("update:options", { ...localOptions.value });
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): string {
|
||||
const base = "h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer";
|
||||
if (state === "checked") {
|
||||
return `${base} bg-primary border-primary text-primary-foreground`;
|
||||
}
|
||||
if (state === "indeterminate") {
|
||||
return `${base} bg-primary border-primary text-primary-foreground`;
|
||||
}
|
||||
return `${base} bg-background border-input hover:border-muted-foreground`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-auto space-y-1">
|
||||
<template v-for="item in optionTree" :key="item.id">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
|
||||
<div :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
|
||||
</div>
|
||||
<div v-if="item.children" class="ml-6 space-y-1">
|
||||
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
|
||||
<div :class="getItemClasses(getChildState(child))">
|
||||
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-4 border-t mt-2">
|
||||
<Button variant="outline" size="sm" @click="handleCancel">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" @click="handleDone">
|
||||
{{ t("common.done") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from "vue";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -203,6 +203,9 @@ async function changeConnection(connectionId: string) {
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, message) }), 5000);
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, message) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,10 +374,13 @@ async function loadMentionCandidates(query: string) {
|
||||
mentionCache.value[key] = candidates.slice(0, 40);
|
||||
mentionCandidates.value = mentionCache.value[key];
|
||||
mentionSelectedIndex.value = 0;
|
||||
} catch (e: unknown) {
|
||||
} catch (e: unknown) {
|
||||
if (requestId !== mentionRequestId) return;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
mentionError.value = translateBackendError(t, message);
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
mentionError.value = translateBackendError(t, message);
|
||||
mentionCandidates.value = [];
|
||||
} finally {
|
||||
if (requestId === mentionRequestId) mentionLoading.value = false;
|
||||
@@ -502,6 +508,7 @@ async function send() {
|
||||
const sessionId = uuid();
|
||||
currentSessionId.value = sessionId;
|
||||
const agentEvents: AgentEvent[] = [];
|
||||
const agentEvents: AgentEvent[] = [];
|
||||
try {
|
||||
const context = await buildAiContext(props.tab, props.connection, {
|
||||
mentionedTables,
|
||||
@@ -510,6 +517,7 @@ async function send() {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
await runAgentStream(
|
||||
await runAgentStream(
|
||||
{
|
||||
config: settings.aiConfig,
|
||||
@@ -554,6 +562,9 @@ async function send() {
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
messages.value[assistantIdx].content = `Error: ${message}`;
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
messages.value[assistantIdx].content = `Error: ${message}`;
|
||||
} finally {
|
||||
const msg = messages.value[assistantIdx];
|
||||
if (msg) msg.isThinking = false;
|
||||
@@ -620,6 +631,9 @@ async function copyCode(code: string, key: string) {
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast(t("grid.copyFailed", { message }), 5000);
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast(t("grid.copyFailed", { message }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +698,7 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
clearTimeout(mentionTimer);
|
||||
cancelStream();
|
||||
cancelStream();
|
||||
});
|
||||
|
||||
function triggerAction(action: AiAction, instruction?: string) {
|
||||
|
||||
@@ -950,6 +950,18 @@ const aiCredentialPlaceholder = computed(() => {
|
||||
if (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer") return "ANTHROPIC_AUTH_TOKEN";
|
||||
return "";
|
||||
});
|
||||
const aiEndpointPlaceholder = computed(() => {
|
||||
if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") {
|
||||
return "https://api.example.com/v1";
|
||||
}
|
||||
return "https://api.openai.com/v1";
|
||||
});
|
||||
const aiEndpointHint = computed(() => {
|
||||
if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") {
|
||||
return "大多数 OpenAI 兼容 API 需要 /v1 路径前缀";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const aiSupportsApiStyle = computed(() => aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom");
|
||||
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
|
||||
const aiCanListModels = computed(() => aiModelListSupported.value && !!aiEditEndpoint.value.trim() && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
|
||||
@@ -2097,9 +2109,12 @@ watch(
|
||||
<Input v-model="aiEditApiKey" type="password" autocomplete="off" class="col-span-2 h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Endpoint</Label>
|
||||
<Input v-model="aiEditEndpoint" placeholder="https://api.openai.com/v1" autocomplete="off" class="col-span-2 h-8 text-xs" />
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">Endpoint</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Input v-model="aiEditEndpoint" :placeholder="aiEndpointPlaceholder" autocomplete="off" class="h-8 text-xs" />
|
||||
<p v-if="aiEndpointHint" class="text-[11px] text-muted-foreground">{{ aiEndpointHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { CustomTheme, CustomThemeColors } from "@/stores/settingsStore";
|
||||
import { DEFAULT_CUSTOM_THEME_COLORS } from "@/stores/settingsStore";
|
||||
import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
|
||||
import { Plus, Trash2, Copy, Pencil, ChevronDown, Palette } from "@lucide/vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -367,6 +367,7 @@ function handleAddTheme() {
|
||||
id,
|
||||
name,
|
||||
colors: { ...DEFAULT_CUSTOM_THEME_COLORS },
|
||||
ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS },
|
||||
});
|
||||
activeEditId.value = id;
|
||||
}
|
||||
@@ -388,6 +389,7 @@ function handleDuplicateTheme(theme: CustomTheme) {
|
||||
id,
|
||||
name: `${theme.name}${t("settings.customThemeCopySuffix")}`,
|
||||
colors: { ...theme.colors },
|
||||
ddlColors: { ...(theme.ddlColors ?? DEFAULT_CUSTOM_THEME_DDL_COLORS) },
|
||||
});
|
||||
activeEditId.value = id;
|
||||
}
|
||||
|
||||
@@ -66,12 +66,13 @@ function onToolbarDblClick(e: MouseEvent) {
|
||||
|
||||
const toolbarEl = ref<HTMLElement>();
|
||||
const toolbarCollapsed = ref(false);
|
||||
const COLLAPSE_THRESHOLD = 1000;
|
||||
|
||||
function checkToolbarWidth() {
|
||||
const el = toolbarEl.value;
|
||||
if (!el) return;
|
||||
toolbarCollapsed.value = el.clientWidth < COLLAPSE_THRESHOLD;
|
||||
const screenWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const threshold = screenWidth / 2;
|
||||
toolbarCollapsed.value = el.clientWidth < threshold;
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
@@ -79,10 +80,12 @@ let resizeObserver: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(checkToolbarWidth);
|
||||
if (toolbarEl.value) resizeObserver.observe(toolbarEl.value);
|
||||
window.addEventListener("resize", checkToolbarWidth);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", checkToolbarWidth);
|
||||
});
|
||||
|
||||
const moreItems = computed(() => {
|
||||
|
||||
Reference in new issue
Block a user