Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1

This commit is contained in:
runstone committed 2026-06-12 18:52:05 +08:00
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(() => {