feat(diagram): enhance ER diagram editing and exports

This commit is contained in:
CGerAJ authored and GitHub committed 2026-08-03 16:05:57 +08:00
1 parent 9fda1d690a
commit 212cf1da7e
92 files changed
+12231 -943

No files matched your search

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { DiagramLayer } from "@/types/diagram";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
layers: DiagramLayer[];
activeLayerId: string | null;
existingNames: string[];
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "create", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
}>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const name = ref("");
const layerId = ref<string | "">("");
const withDefaultId = ref(true);
const error = ref("");
watch(
() => props.open,
(open) => {
if (open) {
name.value = "";
layerId.value = props.activeLayerId || "";
withDefaultId.value = true;
error.value = "";
}
},
);
function submit() {
const trimmed = name.value.trim();
if (!trimmed) {
error.value = t("diagram.tableNameRequired");
return;
}
if (props.existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
error.value = t("diagram.tableNameExists");
return;
}
emit("create", { name: trimmed, layerId: layerId.value || null, withDefaultId: withDefaultId.value });
openModel.value = false;
}
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="max-w-md gap-3">
<DialogHeader>
<DialogTitle>{{ t("diagram.createTable") }}</DialogTitle>
</DialogHeader>
<div class="space-y-3">
<div class="space-y-1">
<label class="text-xs text-muted-foreground">{{ t("diagram.tableName") }}</label>
<Input v-model="name" class="h-8 text-xs" @keydown.enter="submit" />
</div>
<div class="space-y-1">
<label class="text-xs text-muted-foreground">{{ t("diagram.assignLayer") }}</label>
<select v-model="layerId" class="h-8 w-full rounded border border-border bg-background px-2 text-xs">
<option value="">{{ t("diagram.noLayer") }}</option>
<option v-for="layer in layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-xs">
<input v-model="withDefaultId" type="checkbox" />
{{ t("diagram.withDefaultIdPk") }}
</label>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" @click="submit">{{ t("diagram.create") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,721 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2, X, KeyRound } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { DiagramTable, DiagramRelationship, CustomDiagramRelationship } from "@/lib/diagram/erDiagram";
import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isDraftTable, isDroppedColumn, isPendingColumn } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import type { InspectorTarget } from "@/types/diagram";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
import { createDraftIndex, nextUniqueColumnName } from "@/lib/diagram/draft-table";
import { resolveDiagramDialectAdapter } from "@/lib/diagram/diagram-dialect-adapter";
import { cardinalityChoiceFromPair, cardinalityPairFromChoice, edgeCardinalityPair, type CardinalityChoice } from "@/lib/diagram/cardinality";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, dataTypeLengthInputValue, dataTypeLengthUnitValue, getDataTypeLengthUnitOptions, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const { t } = useI18n();
const props = defineProps<{
target: InspectorTarget;
tables: DiagramTable[];
relationships: (DiagramRelationship | InferredRelationship | CustomDiagramRelationship)[];
databaseType?: DatabaseType;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "update-table", table: DiagramTable): void;
(e: "delete-draft-table", tableName: string): void;
(e: "delete-live-table", tableName: string): void;
(e: "update-relationship", payload: { id: string; patch: Partial<CustomDiagramRelationship> }): void;
(e: "remove-relationship", id: string): void;
(e: "save-relationship", payload: Omit<CustomDiagramRelationship, "id"> & { id?: string }): void;
(e: "confirm-relationship", payload: { id: string; sourceCardinality: "1" | "N"; targetCardinality: "1" | "N" }): void;
(e: "ignore-relationship", id: string): void;
}>();
type InspectorTab = "fields" | "indexes";
const tableTab = ref<InspectorTab>("fields");
const confirmingDelete = ref(false);
const confirmingDropTable = ref(false);
const relationshipDraft = reactive({
sourceTable: "",
sourceColumn: "",
targetTable: "",
targetColumn: "",
cardinality: "many-to-one" as CardinalityChoice,
});
const adapter = computed(() => resolveDiagramDialectAdapter(props.databaseType));
const databaseType = computed(() => props.databaseType);
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
const selectedTable = computed(() => {
const target = props.target;
if (target?.kind !== "table") return null;
return props.tables.find((t) => t.name === target.tableName) ?? null;
});
const selectedEdge = computed(() => {
const target = props.target;
if (target?.kind !== "edge") return null;
return props.relationships.find((r) => r.id === target.edgeId) ?? null;
});
const relationshipKind = computed(() => {
const rel = selectedEdge.value;
if (!rel) return "unknown";
if ("kind" in rel) return rel.kind;
return "inferred";
});
const relationshipEditable = computed(() => relationshipKind.value === "custom" || relationshipKind.value === "inferred");
const tableMap = computed(() => new Map(props.tables.map((table) => [table.name, table])));
const sourceColumns = computed(() => tableMap.value.get(relationshipDraft.sourceTable)?.columns ?? []);
const targetColumns = computed(() => tableMap.value.get(relationshipDraft.targetTable)?.columns ?? []);
/** Full draft table edit (rename, indexes, delete draft). */
const editable = computed(() => selectedTable.value != null && isDraftTable(selectedTable.value));
const isLive = computed(() => selectedTable.value != null && !isDraftTable(selectedTable.value));
const canAddField = computed(() => {
if (!selectedTable.value) return false;
return canAddTableStructureColumn(props.databaseType, isDraftTable(selectedTable.value));
});
const showLivePendingHint = computed(() => isLive.value && selectedTable.value != null && canAddField.value);
const canDropLiveColumn = computed(() => isLive.value && structureCapabilities.value.dropColumn);
const canDropLiveTable = computed(() => isLive.value && structureCapabilities.value.createTable);
const supportsCreateIndex = computed(() => structureCapabilities.value.createIndex);
const supportsComment = computed(() => structureCapabilities.value.comment);
function isColumnEditable(columnName: string): boolean {
const table = selectedTable.value;
if (!table) return false;
if (isDroppedColumn(table, columnName)) return false;
if (isDraftTable(table)) return true;
return isPendingColumn(table, columnName);
}
function canRemoveColumn(columnName: string): boolean {
const table = selectedTable.value;
if (!table) return false;
if (isDraftTable(table)) return true;
if (isPendingColumn(table, columnName)) return true;
return canDropLiveColumn.value;
}
const tableIndexes = computed(() => editableStructureIndexes(selectedTable.value ?? { name: "", columns: [], foreignKeys: [] }).filter((index) => !index.markedForDrop));
function syncRelationshipDraft() {
const rel = selectedEdge.value;
if (!rel) return;
relationshipDraft.sourceTable = rel.sourceTable;
relationshipDraft.sourceColumn = rel.sourceColumn;
relationshipDraft.targetTable = rel.targetTable;
relationshipDraft.targetColumn = rel.targetColumn;
relationshipDraft.cardinality = cardinalityChoiceFromPair("sourceCardinality" in rel && "targetCardinality" in rel ? { sourceCardinality: rel.sourceCardinality, targetCardinality: rel.targetCardinality } : undefined);
confirmingDelete.value = false;
}
watch(
() => props.target,
() => {
tableTab.value = "fields";
confirmingDropTable.value = false;
},
);
watch(selectedEdge, syncRelationshipDraft, { immediate: true });
watch(
() => relationshipDraft.sourceTable,
() => {
if (!sourceColumns.value.some((c) => c.name === relationshipDraft.sourceColumn)) {
relationshipDraft.sourceColumn = sourceColumns.value[0]?.name ?? "";
}
},
);
watch(
() => relationshipDraft.targetTable,
() => {
if (!targetColumns.value.some((c) => c.name === relationshipDraft.targetColumn)) {
relationshipDraft.targetColumn = targetColumns.value[0]?.name ?? "";
}
},
);
function patchTable(mutator: (table: DiagramTable) => void, options?: { requireDraft?: boolean }) {
const table = selectedTable.value;
if (!table) return;
if (options?.requireDraft && !isDraftTable(table)) return;
const next: DiagramTable = {
...table,
columns: table.columns.map((c) => ({ ...c })),
foreignKeys: [...table.foreignKeys],
indexes: editableStructureIndexes(table).map((index) => ({
...index,
columns: [...index.columns],
includedColumns: [...index.includedColumns],
})),
pendingColumnNames: table.pendingColumnNames ? [...table.pendingColumnNames] : undefined,
droppedColumnNames: table.droppedColumnNames ? [...table.droppedColumnNames] : undefined,
pendingDrop: table.pendingDrop,
};
mutator(next);
emit("update-table", next);
}
function renameTable(name: string) {
patchTable(
(table) => {
table.name = name.trim() || table.name;
},
{ requireDraft: true },
);
}
function updateColumn(index: number, patch: Partial<ColumnInfo>) {
const table = selectedTable.value;
if (!table) return;
const col = table.columns[index];
if (!col || !isColumnEditable(col.name)) return;
patchTable((next) => {
const target = next.columns[index];
if (!target) return;
const oldName = target.name;
Object.assign(target, patch);
if (patch.name !== undefined && next.pendingColumnNames) {
const idx = next.pendingColumnNames.indexOf(oldName);
if (idx >= 0) next.pendingColumnNames[idx] = String(patch.name);
}
});
}
function addColumn() {
if (!canAddField.value) return;
patchTable((table) => {
const name = nextUniqueColumnName(table.columns);
table.columns.push(adapter.value.createEmptyColumn(name));
if (!isDraftTable(table)) {
table.pendingColumnNames = [...(table.pendingColumnNames ?? []), name];
}
});
}
function removeColumn(index: number) {
const table = selectedTable.value;
if (!table) return;
const removed = table.columns[index]?.name;
if (!removed || !canRemoveColumn(removed)) return;
// Pending add / draft: hard delete (no DB impact yet).
if (isDraftTable(table) || isPendingColumn(table, removed)) {
patchTable((next) => {
next.columns.splice(index, 1);
if (next.pendingColumnNames) {
next.pendingColumnNames = next.pendingColumnNames.filter((name) => name !== removed);
if (next.pendingColumnNames.length === 0) delete next.pendingColumnNames;
}
if (!next.indexes) return;
for (const idx of next.indexes) {
idx.columns = idx.columns.filter((col) => col !== removed);
}
});
return;
}
// Live existing column: soft-mark for DROP COLUMN (toggle).
patchTable((next) => {
const dropped = new Set(next.droppedColumnNames ?? []);
if (dropped.has(removed)) {
dropped.delete(removed);
} else {
dropped.add(removed);
}
next.droppedColumnNames = dropped.size ? [...dropped] : undefined;
});
}
function requestDeleteLiveTable() {
confirmingDropTable.value = true;
}
function confirmDeleteLiveTable() {
const table = selectedTable.value;
if (!table || isDraftTable(table)) return;
confirmingDropTable.value = false;
emit("delete-live-table", table.name);
}
function cancelDeleteLiveTable() {
confirmingDropTable.value = false;
}
function addIndex() {
if (!supportsCreateIndex.value) return;
patchTable(
(table) => {
const existing = editableStructureIndexes(table);
const firstCol = table.columns[0]?.name;
table.indexes = [...existing, createDraftIndex(table.name, firstCol ? [firstCol] : [], existing)];
},
{ requireDraft: true },
);
}
function updateIndex(indexId: string, patch: Partial<EditableStructureIndex>) {
patchTable(
(table) => {
const index = editableStructureIndexes(table).find((item) => item.id === indexId);
if (!index) return;
Object.assign(index, patch);
table.indexes = editableStructureIndexes(table).map((item) => (item.id === indexId ? { ...item, ...patch } : item));
},
{ requireDraft: true },
);
}
function toggleIndexColumn(indexId: string, columnName: string) {
patchTable(
(table) => {
table.indexes = editableStructureIndexes(table).map((index) => {
if (index.id !== indexId) return index;
if (index.columns.includes(columnName)) {
return { ...index, columns: index.columns.filter((col) => col !== columnName) };
}
return { ...index, columns: [...index.columns, columnName] };
});
},
{ requireDraft: true },
);
}
function removeIndex(indexId: string) {
patchTable(
(table) => {
table.indexes = editableStructureIndexes(table).filter((index) => index.id !== indexId);
},
{ requireDraft: true },
);
}
const draftCardinality = computed(() => cardinalityPairFromChoice(relationshipDraft.cardinality));
function directedCardinalityLabel(source: "1" | "N", target: "1" | "N"): string {
return t("diagram.cardinalityDirected", { source, target });
}
const edgeDirectedCardinalityLabel = computed(() => {
const rel = selectedEdge.value;
if (!rel) return directedCardinalityLabel("N", "1");
if (relationshipEditable.value) {
const pair = draftCardinality.value;
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
}
const pair = edgeCardinalityPair(rel);
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
});
function handleSaveRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
const card = cardinalityPairFromChoice(relationshipDraft.cardinality);
emit("save-relationship", {
id: "kind" in rel && rel.kind === "custom" ? rel.id : undefined,
name: "name" in rel ? rel.name : `${relationshipDraft.sourceTable}_${relationshipDraft.sourceColumn}_${relationshipDraft.targetTable}_${relationshipDraft.targetColumn}`,
sourceTable: relationshipDraft.sourceTable,
sourceColumn: relationshipDraft.sourceColumn,
targetTable: relationshipDraft.targetTable,
targetColumn: relationshipDraft.targetColumn,
...card,
});
}
function handleConfirmRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
emit("confirm-relationship", { id: rel.id, ...cardinalityPairFromChoice(relationshipDraft.cardinality) });
}
function handleIgnoreRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
emit("ignore-relationship", rel.id);
}
function confirmDeleteRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
confirmingDelete.value = false;
emit("remove-relationship", rel.id);
}
function columnBaseType(dataType: string): string {
return splitDataType(dataType).baseType;
}
function columnLengthEnabled(dataType: string): boolean {
return !isDataTypeLengthDisabled(databaseType.value, columnBaseType(dataType));
}
function columnLengthUnitOptions(dataType: string): readonly string[] {
return getDataTypeLengthUnitOptions(databaseType.value, dataType);
}
function updateColumnBaseType(index: number, baseType: string) {
const next = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType));
updateColumn(index, { data_type: next });
}
function updateColumnLength(index: number, value: string | number) {
const table = selectedTable.value;
const col = table?.columns[index];
if (!col) return;
const baseType = columnBaseType(col.data_type);
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, String(value), dataTypeLengthUnitValue(databaseType.value, col.data_type));
updateColumn(index, { data_type: next });
}
function updateColumnLengthUnit(index: number, value: unknown) {
const table = selectedTable.value;
const col = table?.columns[index];
if (!col) return;
const unit = value === "__default" || value == null ? "" : String(value);
const baseType = columnBaseType(col.data_type);
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, col.data_type), unit);
updateColumn(index, { data_type: next });
}
const dataTypeOptionsForColumn = computed(() => {
const options = getDataTypeOptions(props.databaseType);
const table = selectedTable.value;
if (!table) return options;
const seen = new Set(options.map((type) => type.toLowerCase()));
const extras: string[] = [];
for (const col of table.columns) {
const base = columnBaseType(col.data_type);
if (!base || seen.has(base.toLowerCase())) continue;
seen.add(base.toLowerCase());
extras.push(base);
}
return extras.length ? [...options, ...extras] : options;
});
</script>
<template>
<div class="flex h-full min-w-0 flex-col overflow-hidden">
<div class="flex items-center justify-between gap-2 border-b border-border px-3 py-2 shrink-0">
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 truncate text-sm font-semibold text-foreground">
<template v-if="selectedTable">{{ t("diagram.inspectorTable") }} · {{ selectedTable.name }}</template>
<template v-else-if="selectedEdge">{{ t("diagram.inspectorRelationship") }}</template>
</h3>
<Badge v-if="selectedTable && editable" variant="outline" class="h-5 shrink-0 text-[10px]">Draft</Badge>
<Badge v-else-if="selectedTable && selectedTable.pendingDrop" variant="destructive" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingDropTableBadge") }}</Badge>
<Badge v-else-if="selectedTable && (hasPendingColumns(selectedTable) || hasDroppedColumns(selectedTable))" variant="outline" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingColumnsBadge") }}</Badge>
</div>
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" @click="emit('close')">
<X class="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<template v-if="selectedTable">
<div class="space-y-1">
<label class="text-[10px] text-muted-foreground">{{ t("diagram.tableName") }}</label>
<Input class="h-8 text-xs" :model-value="selectedTable.name" :disabled="!editable" @update:model-value="(v: string | number) => renameTable(String(v))" />
</div>
<div class="flex border-b border-border">
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'fields' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'fields'">
{{ t("diagram.tabFields") }}
</button>
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'indexes' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'indexes'">
{{ t("diagram.tabIndexes") }}
</button>
</div>
<template v-if="tableTab === 'fields'">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ t("diagram.fields") }}</span>
<Button v-if="canAddField" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addColumn">
<Plus class="mr-1 h-3 w-3" />
{{ t("diagram.addField") }}
</Button>
</div>
<p v-if="showLivePendingHint" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableAddColumnsHint") }}</p>
<div class="space-y-2">
<div v-for="(col, index) in selectedTable.columns" :key="`field-${index}`" class="rounded border p-2 space-y-1.5" :class="isDroppedColumn(selectedTable, col.name) ? 'border-destructive/50 bg-destructive/5 opacity-70' : 'border-border/80'">
<div class="flex items-center gap-1">
<KeyRound v-if="col.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Input class="h-7 flex-1 font-mono text-[11px]" :class="isDroppedColumn(selectedTable, col.name) ? 'line-through' : ''" :model-value="col.name" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: string | number) => updateColumn(index, { name: String(v) })" />
<Badge v-if="isDroppedColumn(selectedTable, col.name)" variant="outline" class="h-5 shrink-0 text-[10px] text-destructive">{{ t("diagram.pendingDropColumnBadge") }}</Badge>
<button v-if="canRemoveColumn(col.name)" type="button" class="rounded p-1 hover:bg-muted" :title="isDroppedColumn(selectedTable, col.name) ? t('diagram.undoDropField') : t('diagram.deleteField')" @click="removeColumn(index)">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<SearchableSelect
class="min-w-0 flex-1 basis-[7rem]"
:model-value="columnBaseType(col.data_type)"
:options="dataTypeOptionsForColumn"
:placeholder="t('structureEditor.typePlaceholder')"
:search-placeholder="t('structureEditor.typePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:allow-custom="true"
:disabled="!isColumnEditable(col.name)"
:trigger-class="['h-7 w-full justify-between px-2 text-[11px] font-mono']"
@update:model-value="(v: string) => updateColumnBaseType(index, v)"
/>
<div v-if="columnLengthEnabled(col.data_type)" class="flex min-w-0 items-center gap-1">
<Input
class="h-7 w-16 shrink-0 font-mono text-[11px]"
:model-value="dataTypeLengthInputValue(databaseType, col.data_type)"
:disabled="!isColumnEditable(col.name)"
:placeholder="t('structureEditor.length')"
@update:model-value="(v: string | number) => updateColumnLength(index, v)"
/>
<Select v-if="columnLengthUnitOptions(col.data_type).length" :model-value="dataTypeLengthUnitValue(databaseType, col.data_type) || '__default'" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: unknown) => updateColumnLengthUnit(index, v)">
<SelectTrigger class="h-7 w-14 shrink-0 px-1 text-[10px] font-mono" :aria-label="t('structureEditor.lengthUnit')" :title="t('structureEditor.lengthUnit')">
<SelectValue :placeholder="t('structureEditor.unitPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default">{{ t("structureEditor.defaultAction") }}</SelectItem>
<SelectItem v-for="unit in columnLengthUnitOptions(col.data_type)" :key="unit" :value="unit">{{ unit }}</SelectItem>
</SelectContent>
</Select>
</div>
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="col.is_primary_key" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_primary_key: ($event.target as HTMLInputElement).checked })" />
PK
</label>
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="col.is_nullable" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_nullable: ($event.target as HTMLInputElement).checked })" />
NULL
</label>
</div>
<Input v-if="supportsComment" class="h-7 text-[11px]" :model-value="col.comment ?? ''" :disabled="!isColumnEditable(col.name)" :placeholder="t('diagram.fieldComment')" @update:model-value="(v: string | number) => updateColumn(index, { comment: String(v) })" />
</div>
<p v-if="selectedTable.columns.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</p>
<p v-else-if="!canAddField && isLive" class="text-[11px] text-muted-foreground">{{ t("diagram.addColumnNotSupported") }}</p>
</div>
</template>
<template v-else>
<div v-if="!supportsCreateIndex" class="text-[11px] text-muted-foreground">
{{ t("diagram.indexesNotSupported") }}
</div>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ t("diagram.tabIndexes") }}</span>
<Button v-if="editable" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addIndex">
<Plus class="mr-1 h-3 w-3" />
{{ t("diagram.addIndex") }}
</Button>
</div>
<div v-if="!editable" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableIndexesReadOnly") }}</div>
<div v-else class="space-y-2">
<div v-for="index in tableIndexes" :key="index.id" class="rounded border border-border/80 p-2 space-y-1.5">
<div class="flex items-center gap-1">
<Input class="h-7 flex-1 font-mono text-[11px]" :model-value="index.name" :placeholder="t('diagram.indexName')" @update:model-value="(v: string | number) => updateIndex(index.id, { name: String(v) })" />
<button type="button" class="rounded p-1 hover:bg-muted" :title="t('diagram.deleteField')" @click="removeIndex(index.id)">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="index.isUnique" @change="updateIndex(index.id, { isUnique: ($event.target as HTMLInputElement).checked })" />
{{ t("diagram.indexUnique") }}
</label>
<div class="space-y-1">
<div class="text-[10px] text-muted-foreground">{{ t("diagram.indexColumns") }}</div>
<div v-if="selectedTable.columns.length === 0" class="text-[10px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</div>
<div v-else class="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border/60 p-1.5">
<label v-for="col in selectedTable.columns" :key="`${index.id}-${col.name}`" class="flex items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
<input type="checkbox" :checked="index.columns.includes(col.name)" @change="toggleIndexColumn(index.id, col.name)" />
<span class="font-mono truncate">{{ col.name }}</span>
</label>
</div>
</div>
</div>
<p v-if="tableIndexes.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noIndexesYet") }}</p>
</div>
</template>
</template>
<Button v-if="editable" type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="emit('delete-draft-table', selectedTable.name)">
{{ t("diagram.deleteDraftTable") }}
</Button>
<template v-else-if="isLive && canDropLiveTable">
<div v-if="!confirmingDropTable" class="space-y-1">
<Button type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="requestDeleteLiveTable">
{{ t("diagram.deleteLiveTable") }}
</Button>
<p class="text-[10px] text-muted-foreground">{{ t("diagram.deleteLiveTableHint") }}</p>
</div>
<div v-else class="space-y-2 rounded border border-destructive/40 bg-destructive/5 p-2">
<p class="text-[11px] text-destructive">{{ t("diagram.deleteLiveTableConfirm") }}</p>
<div class="flex gap-2">
<Button type="button" variant="destructive" size="sm" class="h-7 flex-1 text-[11px]" @click="confirmDeleteLiveTable">{{ t("common.confirm") }}</Button>
<Button type="button" variant="outline" size="sm" class="h-7 flex-1 text-[11px]" @click="cancelDeleteLiveTable">{{ t("common.cancel") }}</Button>
</div>
</div>
</template>
<p v-else-if="isLive && !canDropLiveTable" class="text-[11px] text-muted-foreground">{{ t("diagram.dropTableNotSupported") }}</p>
</template>
<template v-else-if="selectedEdge">
<div class="space-y-3 text-xs">
<div class="text-[10px] text-muted-foreground">
<template v-if="relationshipKind === 'foreign-key'">{{ t("diagram.relationshipKindFk") }}</template>
<template v-else-if="relationshipKind === 'custom'">{{ t("diagram.relationshipKindCustom") }}</template>
<template v-else-if="relationshipKind === 'inferred'">{{ t("diagram.relationshipKindInferred") }}</template>
<span> · {{ edgeDirectedCardinalityLabel }}</span>
<span v-if="'confidence' in selectedEdge"> · {{ selectedEdge.confidence }}</span>
</div>
<p v-if="relationshipKind === 'foreign-key'" class="text-[11px] text-muted-foreground">
{{ t("diagram.relationshipReadOnlyFk") }}
</p>
<template v-if="relationshipKind === 'foreign-key'">
<div>
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.source") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ edgeCardinalityPair(selectedEdge).sourceCardinality }}
</span>
</div>
<div class="font-mono">{{ selectedEdge.sourceTable }}.{{ selectedEdge.sourceColumn }}</div>
</div>
<div>
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.target") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ edgeCardinalityPair(selectedEdge).targetCardinality }}
</span>
</div>
<div class="font-mono">{{ selectedEdge.targetTable }}.{{ selectedEdge.targetColumn }}</div>
</div>
<div>
<div class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</div>
<div>{{ edgeDirectedCardinalityLabel }}</div>
</div>
</template>
<template v-else-if="relationshipEditable">
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.sourceTable") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.sourceCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.sourceTable">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`s-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.sourceColumn") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.sourceCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.sourceColumn">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="col in sourceColumns" :key="`sc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</label>
<Select v-model="relationshipDraft.cardinality">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.targetTable") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.targetCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.targetTable">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`t-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.targetColumn") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.targetCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.targetColumn">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="col in targetColumns" :key="`tc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1.5 pt-1">
<Button v-if="relationshipKind === 'custom' || relationshipKind === 'inferred'" type="button" size="sm" class="h-8 w-full text-xs" @click="handleSaveRelationship">
{{ t("diagram.saveRelationship") }}
</Button>
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleConfirmRelationship">
{{ t("diagram.confirmRelationship") }}
</Button>
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleIgnoreRelationship">
{{ t("diagram.ignoreRelationship") }}
</Button>
</div>
<template v-if="relationshipKind === 'custom'">
<div v-if="confirmingDelete" class="space-y-2 rounded border border-destructive/30 bg-destructive/5 p-2">
<p class="text-[11px] text-muted-foreground">{{ t("diagram.confirmDeleteRelationship") }}</p>
<div class="flex gap-1.5">
<Button type="button" size="sm" variant="destructive" class="h-7 flex-1 text-xs" @click="confirmDeleteRelationship">
{{ t("diagram.confirmDeleteRelationshipAction") }}
</Button>
<Button type="button" size="sm" variant="outline" class="h-7 flex-1 text-xs" @click="confirmingDelete = false">
{{ t("common.cancel") }}
</Button>
</div>
</div>
<Button v-else type="button" variant="destructive" size="sm" class="h-8 w-full text-xs" @click="confirmingDelete = true">
{{ t("diagram.removeRelationship") }}
</Button>
</template>
</template>
</div>
</template>
</div>
</div>
</template>
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import { isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
import { draftTableToCreateSqlOptions, hasLiveColumnChanges, liveTableToAlterSqlOptions, validateDraftTable, validateLivePendingColumns } from "@/lib/diagram/draft-table";
import { buildDropTableSql } from "@/lib/database/dbAdminSql";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import type { DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
tables: DiagramTable[];
connectionId: string;
database: string;
schema: string;
databaseType?: DatabaseType;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "synced", tableNames: string[]): void;
}>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const syncTables = computed(() => props.tables.filter(needsDiagramSync));
const draftTables = computed(() => syncTables.value.filter(isDraftTable));
const liveDropTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !!table.pendingDrop));
const liveAlterTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !table.pendingDrop && hasLiveColumnChanges(table)));
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
const validationErrors = ref<string[]>([]);
const sqlText = ref("");
const warnings = ref<string[]>([]);
const building = ref(false);
const executing = ref(false);
const execError = ref("");
function validateStructureCapabilities(): string[] {
const caps = structureCapabilities.value;
const errors: string[] = [];
if (draftTables.value.length && !caps.createTable) {
errors.push(t("diagram.createTableNotSupported"));
}
if (liveAlterTables.value.some((table) => (table.pendingColumnNames?.length ?? 0) > 0) && !caps.addColumn) {
errors.push(t("diagram.addColumnNotSupported"));
}
if (liveAlterTables.value.some((table) => (table.droppedColumnNames?.length ?? 0) > 0) && !caps.dropColumn) {
errors.push(t("diagram.dropColumnNotSupported"));
}
if (liveDropTables.value.length && !caps.createTable) {
errors.push(t("diagram.dropTableNotSupported"));
}
return errors;
}
async function rebuildSql() {
building.value = true;
validationErrors.value = [];
warnings.value = [];
sqlText.value = "";
execError.value = "";
try {
const capabilityErrors = validateStructureCapabilities();
const errors = [...capabilityErrors, ...draftTables.value.flatMap(validateDraftTable), ...liveAlterTables.value.flatMap(validateLivePendingColumns)];
if (errors.length) {
validationErrors.value = errors;
return;
}
const allStatements: string[] = [];
const allWarnings: string[] = [];
// Same SQL APIs as TableStructureEditor — do not generate dialect SQL in the diagram layer.
for (const table of draftTables.value) {
const result = await api.buildCreateTableSql(draftTableToCreateSqlOptions(table, props.databaseType, props.schema || undefined));
allStatements.push(...result.statements);
allWarnings.push(...result.warnings);
}
for (const table of liveAlterTables.value) {
// Live ER sync only maps ADD/DROP COLUMN. Existing-column type changes stay in TableStructureEditor
// (including SQLite rebuild via previewSqliteTableStructureChange).
const result = await api.buildTableStructureChangeSql(liveTableToAlterSqlOptions(table, props.databaseType, props.schema || undefined));
allStatements.push(...result.statements);
allWarnings.push(...result.warnings);
}
for (const table of liveDropTables.value) {
const sql = await buildDropTableSql({
databaseType: props.databaseType,
schema: props.schema || undefined,
tableName: table.name,
cascade: false,
});
if (sql.trim()) allStatements.push(sql.trim());
}
sqlText.value = allStatements.join("\n\n");
warnings.value = allWarnings;
} catch (e: any) {
validationErrors.value = [e?.message || String(e)];
} finally {
building.value = false;
}
}
watch(
() => props.open,
(open) => {
if (open) void rebuildSql();
},
);
async function copySql() {
if (!sqlText.value) return;
await copyToClipboard(sqlText.value);
}
async function execute() {
if (!sqlText.value || validationErrors.value.length) return;
executing.value = true;
execError.value = "";
try {
const statements = sqlText.value
.split(/;\s*\n/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => (s.endsWith(";") ? s : `${s};`));
await api.executeBatch(props.connectionId, props.database, statements, props.schema || undefined);
emit(
"synced",
syncTables.value.map((table) => table.name),
);
openModel.value = false;
} catch (e: any) {
execError.value = e?.message || String(e);
} finally {
executing.value = false;
}
}
function syncTableLabel(table: DiagramTable): string {
if (isDraftTable(table)) {
return `${table.name} (${table.columns.length} cols, CREATE)`;
}
if (table.pendingDrop) {
return `${table.name} (DROP TABLE)`;
}
const added = table.pendingColumnNames?.length ?? 0;
const dropped = table.droppedColumnNames?.length ?? 0;
const parts: string[] = [];
if (added) parts.push(`+${added}`);
if (dropped) parts.push(`-${dropped}`);
return `${table.name} (${parts.join("/") || "0"} cols, ALTER)`;
}
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col gap-3">
<DialogHeader>
<DialogTitle>{{ t("diagram.syncToDatabase") }}</DialogTitle>
</DialogHeader>
<div class="text-xs text-muted-foreground">
{{ t("diagram.syncDraftCount", { count: syncTables.length }) }}
</div>
<ul v-if="syncTables.length" class="text-xs list-disc pl-4 space-y-0.5">
<li v-for="table in syncTables" :key="table.name" class="font-mono">{{ syncTableLabel(table) }}</li>
</ul>
<div v-if="validationErrors.length" class="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive space-y-1">
<div v-for="(err, i) in validationErrors" :key="i">{{ err }}</div>
</div>
<div v-if="warnings.length" class="rounded border border-amber-500/40 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-300 space-y-1">
<div v-for="(w, i) in warnings" :key="i">{{ w }}</div>
</div>
<pre class="flex-1 min-h-[160px] max-h-[40vh] overflow-auto rounded border bg-muted/30 p-3 text-[11px] font-mono whitespace-pre-wrap">{{ building ? t("diagram.buildingSql") : sqlText || t("diagram.noSqlYet") }}</pre>
<p v-if="execError" class="text-xs text-destructive">{{ execError }}</p>
<DialogFooter class="gap-2 sm:gap-2">
<Button type="button" variant="outline" size="sm" :disabled="!sqlText" @click="copySql">{{ t("diagram.copySql") }}</Button>
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" :disabled="!sqlText || !!validationErrors.length || executing || building" @click="execute">
{{ executing ? t("diagram.syncing") : t("diagram.executeSync") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Copy, Download, Link2, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Search, Table2, Upload, X, ZoomIn, ZoomOut, LayoutGrid } from "@lucide/vue";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import type { ConnectionConfig } from "@/types/database";
import type { DiagramExportFormat } from "@/lib/export/diagramFormats";
const { t } = useI18n();
const props = defineProps<{
connectionId: string;
database: string;
schema: string;
databases: string[];
schemas: string[];
sqlConnections: ConnectionConfig[];
selectedConnection: ConnectionConfig | undefined;
isSchemaAware: boolean;
loadingDatabases: boolean;
loadingSchemas: boolean;
loadingDiagram: boolean;
diagramReady: boolean;
tablesCount: number;
relationshipsCount: number;
customRelationshipCount: number;
matchRelationshipCount: number;
diagramMode: "table" | "engineering";
tableSearch: string;
showMatchPanel: boolean;
showLayersPanel: boolean;
showAllTables: boolean;
focusTableName: string;
generatedJoinSql: string;
isFullscreen?: boolean;
draftTableCount?: number;
canCreateTable?: boolean;
canSyncToDatabase?: boolean;
}>();
const emit = defineEmits<{
(e: "set-connection", value: string): void;
(e: "set-database", value: string): void;
(e: "set-schema", value: string): void;
(e: "update:table-search", value: string): void;
(e: "set-diagram-mode", value: "table" | "engineering"): void;
(e: "toggle-match-panel"): void;
(e: "toggle-layers-panel"): void;
(e: "copy-join-sql"): void;
(e: "toggle-show-all-tables"): void;
(e: "export-format", format: DiagramExportFormat): void;
(e: "refresh"): void;
(e: "zoom-out"): void;
(e: "zoom-in"): void;
(e: "toggle-fullscreen"): void;
(e: "auto-layout"): void;
(e: "create-table"): void;
(e: "sync-to-database"): void;
}>();
const EXPORT_FORMATS: DiagramExportFormat[] = ["svg", "png", "json", "dbml", "mermaid"];
function exportFormatLabel(format: DiagramExportFormat): string {
switch (format) {
case "svg":
return t("diagram.exportSvg");
case "png":
return t("diagram.exportPng");
case "json":
return t("diagram.exportJson");
case "dbml":
return t("diagram.exportDbml");
case "mermaid":
return t("diagram.exportMermaid");
}
}
function connectionIconType(id: string) {
const config = props.sqlConnections.find((c) => c.id === id);
return config?.driver_profile || config?.db_type || "mysql";
}
</script>
<template>
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
<Select :model-value="connectionId" @update:model-value="(value: any) => emit('set-connection', String(value))">
<SelectTrigger class="h-8 w-48 text-xs">
<div v-if="connectionId" class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ selectedConnection?.name }}</span>
</div>
<SelectValue v-else :placeholder="t('diagram.selectConnection')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connection.driver_profile || connection.db_type" class="w-3.5 h-3.5 shrink-0" />
<ConnectionGroupBadge :connection-id="connection.id" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => emit('set-database', String(value))">
<SelectTrigger class="h-8 w-44 text-xs">
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<Select v-if="isSchemaAware" :model-value="schema" :disabled="!schemas.length || loadingSchemas" @update:model-value="(value: any) => emit('set-schema', String(value))">
<SelectTrigger class="h-8 w-40 text-xs">
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="name in schemas" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input :value="tableSearch" @input="(e: Event) => emit('update:table-search', (e.target as HTMLInputElement).value)" class="h-8 pl-7 pr-9 text-xs" :placeholder="t('diagram.searchTables')" />
<button v-if="tableSearch" type="button" class="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-muted-foreground/20 hover:bg-muted-foreground/40 flex items-center justify-center transition-colors" @click="emit('update:table-search', '')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
</div>
<div class="flex h-8 shrink-0 items-center overflow-hidden rounded-md border bg-background">
<Button variant="ghost" size="sm" class="h-8 rounded-none px-2 text-xs" :class="diagramMode === 'table' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'table')">
<Table2 class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.tableMode") }}
</Button>
<Button variant="ghost" size="sm" class="h-8 rounded-none border-l px-2 text-xs" :class="diagramMode === 'engineering' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'engineering')">
<Network class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.engineeringMode") }}
</Button>
</div>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady || canCreateTable === false" :title="canCreateTable === false ? t('diagram.createTableNotSupported') : t('diagram.createTable')" @click="emit('create-table')">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.createTable") }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8 px-2 text-xs"
:disabled="!diagramReady || !(draftTableCount ?? 0) || canSyncToDatabase === false"
:title="canSyncToDatabase === false ? t('diagram.structureSyncNotSupported') : t('diagram.syncToDatabase')"
:class="(draftTableCount ?? 0) > 0 ? 'bg-primary/10 border-primary text-primary' : ''"
@click="emit('sync-to-database')"
>
<Upload class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.syncToDatabase") }}
<Badge v-if="(draftTableCount ?? 0) > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">{{ draftTableCount }}</Badge>
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.modelRelationships')" :class="showMatchPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-match-panel')">
<Link2 class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.modelRelationships") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady" :title="t('diagram.layers')" :class="showLayersPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-layers-panel')">
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.layers") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.autoLayout')" @click="emit('auto-layout')">
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.autoLayout") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!generatedJoinSql.trim()" :title="t('diagram.copyJoinSql')" @click="emit('copy-join-sql')">
<Copy class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.copyJoinSql") }}
</Button>
<Button v-if="focusTableName && tablesCount > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
</Button>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t("diagram.tablesCount", { count: tablesCount }) }}
</Badge>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t("diagram.relationshipsCount", { count: relationshipsCount }) }}
</Badge>
<Badge v-if="matchRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
{{ t("diagram.matchRelationshipsCount", { count: matchRelationshipCount }) }}
</Badge>
<Badge v-if="customRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
{{ t("diagram.customRelationshipsCount", { count: customRelationshipCount }) }}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || tablesCount === 0" :title="t('diagram.export')">
<Download class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem v-for="format in EXPORT_FORMATS" :key="format" @click="emit('export-format', format)">
{{ exportFormatLabel(format) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="emit('refresh')">
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomOut')" @click="emit('zoom-out')">
<ZoomOut class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomIn')" @click="emit('zoom-in')">
<ZoomIn class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="isFullscreen ? t('diagram.exitFullscreen') : t('diagram.fullscreen')" @click="emit('toggle-fullscreen')">
<Minimize2 v-if="isFullscreen" class="h-4 w-4" />
<Maximize2 v-else class="h-4 w-4" />
</Button>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Lock, Unlock } from "@lucide/vue";
import type { DiagramLayer } from "@/types/diagram";
defineProps<{
data: {
layer: DiagramLayer;
};
}>();
const { t } = useI18n();
function isLocked(layer: DiagramLayer): boolean {
return (layer.layoutMode ?? "auto") === "free";
}
</script>
<template>
<div
class="relative rounded-lg border-2 bg-background/40 box-border pointer-events-none overflow-hidden"
:style="{
borderColor: data.layer.color,
width: '100%',
height: '100%',
}"
>
<div class="layer-drag-handle absolute top-0 left-0 right-0 h-10 flex items-center gap-2 px-4 rounded-t-lg cursor-grab active:cursor-grabbing pointer-events-auto" :style="{ backgroundColor: data.layer.color + '33' }">
<span class="text-sm font-semibold truncate" :style="{ color: data.layer.color }">
{{ data.layer.name }}
</span>
<span class="ml-auto shrink-0 flex items-center gap-1 text-[10px] font-normal opacity-70" :title="isLocked(data.layer) ? t('diagram.layerLocked') : t('diagram.layerUnlocked')">
<Lock v-if="isLocked(data.layer)" class="h-3 w-3" :style="{ color: data.layer.color }" />
<Unlock v-else class="h-3 w-3" :style="{ color: data.layer.color }" />
</span>
</div>
</div>
</template>
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2, Eye, EyeOff, ChevronDown, ChevronRight, Edit3, Check, X, CheckSquare, Square, Lock, Unlock } from "@lucide/vue";
import { useLayerStore } from "@/lib/diagram/layer-store";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import { filterAssignableDiagramTables } from "@/lib/diagram/erDiagram";
import type { LayerLayoutMode } from "@/types/diagram";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
tables: DiagramTable[];
recordHistory?: () => void;
}>();
const emit = defineEmits<{
(e: "layer-changed"): void;
(e: "add-layer"): void;
(e: "layout-mode-changed", payload: { layerId: string; layoutMode: LayerLayoutMode }): void;
(e: "focus-layer", layerId: string): void;
(e: "create-draft-table", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
(e: "delete-table", tableName: string): void;
}>();
const store = useLayerStore();
const { toast } = useToast();
const { t } = useI18n();
function beforeMutate() {
props.recordHistory?.();
}
type AddTableTab = "existing" | "create";
const editingLayerId = ref<string | null>(null);
const editingName = ref("");
const renameInputRef = ref<HTMLInputElement | null>(null);
const selectedTableNames = ref<Map<string, Set<string>>>(new Map());
const addTableFilter = ref("");
/** Layer ids whose "tables in other layers" section is expanded (default: collapsed). */
const otherLayersExpandedByLayer = ref<Set<string>>(new Set());
const addTableTabByLayerId = reactive<Record<string, AddTableTab>>({});
const createNameByLayerId = reactive<Record<string, string>>({});
const createWithIdByLayerId = reactive<Record<string, boolean>>({});
const createErrorByLayerId = reactive<Record<string, string>>({});
function getAddTableTab(layerId: string): AddTableTab {
return addTableTabByLayerId[layerId] || "existing";
}
function ensureCreateFormDefaults(layerId: string) {
if (createNameByLayerId[layerId] === undefined) createNameByLayerId[layerId] = "";
if (createWithIdByLayerId[layerId] === undefined) createWithIdByLayerId[layerId] = true;
}
function setAddTableTab(layerId: string, tab: AddTableTab) {
addTableTabByLayerId[layerId] = tab;
if (tab === "create") ensureCreateFormDefaults(layerId);
}
function submitCreateDraft(layerId: string) {
const trimmed = (createNameByLayerId[layerId] || "").trim();
if (!trimmed) {
createErrorByLayerId[layerId] = t("diagram.tableNameRequired");
return;
}
if (props.tables.some((tbl) => tbl.name.toLowerCase() === trimmed.toLowerCase())) {
createErrorByLayerId[layerId] = t("diagram.tableNameExists");
return;
}
delete createErrorByLayerId[layerId];
createNameByLayerId[layerId] = "";
emit("create-draft-table", {
name: trimmed,
layerId,
withDefaultId: createWithIdByLayerId[layerId] ?? true,
});
}
function getSelectedTables(layerId: string): Set<string> {
return selectedTableNames.value.get(layerId) || new Set();
}
function toggleSelectedTable(layerId: string, tableName: string) {
const selected = selectedTableNames.value.get(layerId) || new Set();
if (selected.has(tableName)) {
selected.delete(tableName);
} else {
selected.add(tableName);
}
selectedTableNames.value.set(layerId, selected);
}
function isTableSelected(layerId: string, tableName: string): boolean {
return getSelectedTables(layerId).has(tableName);
}
const assignableTables = computed(() => filterAssignableDiagramTables(props.tables));
const availableTables = computed(() => {
return assignableTables.value.filter((table) => !store.getLayerByTable(table.name));
});
function matchesAddTableFilter(name: string): boolean {
const q = addTableFilter.value.trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().includes(q);
}
const filteredAvailableTables = computed(() => {
return availableTables.value.filter((table) => matchesAddTableFilter(table.name));
});
function getOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return assignableTables.value.filter((table) => getOtherLayerForTable(table.name, excludeLayerId));
}
function getFilteredOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return getOtherLayerTables(excludeLayerId).filter((table) => matchesAddTableFilter(table.name));
}
function isOtherLayersExpanded(layerId: string): boolean {
return otherLayersExpandedByLayer.value.has(layerId);
}
function toggleOtherLayersExpanded(layerId: string) {
const next = new Set(otherLayersExpandedByLayer.value);
if (next.has(layerId)) {
next.delete(layerId);
} else {
next.add(layerId);
}
otherLayersExpandedByLayer.value = next;
}
function getOtherLayerForTable(tableName: string, excludeLayerId: string): string | null {
const layer = store.layers.find((l) => l.id !== excludeLayerId && l.tableNames.includes(tableName));
return layer?.name || null;
}
function handleAddSelectedTables(layerId: string) {
const selected = getSelectedTables(layerId);
if (selected.size === 0) return;
beforeMutate();
selected.forEach((tableName) => {
const currentLayer = store.getLayerByTable(tableName);
if (currentLayer) {
store.removeTableFromLayer(currentLayer.id, tableName);
}
store.addTableToLayer(layerId, tableName);
});
selectedTableNames.value.delete(layerId);
const next = new Set(otherLayersExpandedByLayer.value);
next.delete(layerId);
otherLayersExpandedByLayer.value = next;
addTableFilter.value = "";
emit("layer-changed");
}
function selectAllAvailableTables(layerId: string) {
const all = new Set(filteredAvailableTables.value.map((t) => t.name));
selectedTableNames.value.set(layerId, all);
}
function selectAllTablesFromOtherLayers(layerId: string) {
const selected = getSelectedTables(layerId);
getFilteredOtherLayerTables(layerId).forEach((table) => {
selected.add(table.name);
});
selectedTableNames.value.set(layerId, selected);
}
function clearSelection(layerId: string) {
selectedTableNames.value.set(layerId, new Set());
}
function handleAddLayer() {
emit("add-layer");
}
function handleRemoveLayer(layerId: string) {
beforeMutate();
store.removeLayer(layerId);
selectedTableNames.value.delete(layerId);
delete addTableTabByLayerId[layerId];
delete createNameByLayerId[layerId];
delete createWithIdByLayerId[layerId];
delete createErrorByLayerId[layerId];
const next = new Set(otherLayersExpandedByLayer.value);
next.delete(layerId);
otherLayersExpandedByLayer.value = next;
emit("layer-changed");
}
function handleRenameLayer(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
const newName = editingName.value.trim();
if (!newName) {
cancelRename();
return;
}
if (newName.length > 50) {
toast(t("diagram.layerNameTooLong"), 3000);
return;
}
// Unicode letters/digits (incl. Chinese); allow space, _, -, . in the middle
const nameRegex = /^[\p{L}\p{N}][\p{L}\p{N}_\-.\s]*$/u;
if (!nameRegex.test(newName)) {
toast(t("diagram.layerNameInvalid"), 3000);
return;
}
const existingLayer = store.layers.find((l) => l.id !== layerId && l.name === newName);
if (existingLayer) {
toast(t("diagram.layerNameExists"), 3000);
return;
}
if (newName === layer.name) {
cancelRename();
return;
}
beforeMutate();
store.renameLayer(layerId, newName);
editingLayerId.value = null;
editingName.value = "";
emit("layer-changed");
}
function cancelRename() {
editingLayerId.value = null;
editingName.value = "";
}
async function startRename(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
editingLayerId.value = layerId;
editingName.value = layer.name;
await nextTick();
const focusRenameInput = () => {
renameInputRef.value?.focus();
renameInputRef.value?.select();
};
focusRenameInput();
// dblclick 前的 click 会触发 focus-layer → fitView,延后一帧避免被抢走焦点
requestAnimationFrame(focusRenameInput);
}
function handleRemoveTable(layerId: string, tableName: string) {
beforeMutate();
store.removeTableFromLayer(layerId, tableName);
emit("layer-changed");
}
function handleDeleteTable(tableName: string) {
emit("delete-table", tableName);
}
function handleToggleVisibility(layerId: string) {
beforeMutate();
store.toggleLayerVisibility(layerId);
emit("layer-changed");
}
function isLayerLocked(layer: { layoutMode?: LayerLayoutMode }): boolean {
return (layer.layoutMode ?? "auto") === "free";
}
function handleToggleLayoutLock(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
const nextMode: LayerLayoutMode = isLayerLocked(layer) ? "auto" : "free";
emit("layout-mode-changed", { layerId, layoutMode: nextMode });
}
function handleToggleCollapse(layerId: string) {
store.toggleLayerCollapse(layerId);
}
function handleSetActiveLayer(layerId: string) {
store.setActiveLayer(layerId);
emit("focus-layer", layerId);
}
function getLayerTables(layerId: string): DiagramTable[] {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return [];
return assignableTables.value.filter((table) => layer.tableNames.includes(table.name));
}
function getLayerColor(layerId: string): string {
const layer = store.layers.find((l) => l.id === layerId);
return layer?.color || "#ccc";
}
</script>
<template>
<div class="flex flex-col h-full max-h-full min-h-0 bg-background border-r border-border">
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<h3 class="text-sm font-semibold text-foreground">Layers</h3>
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" title="Add Layer" @click="handleAddLayer">
<Plus class="h-4 w-4 text-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<div v-if="store.layers.length === 0" class="text-xs text-muted-foreground text-center py-4">No layers yet. Click + to create one.</div>
<div v-for="layer in store.layers" :key="layer.id" class="rounded-md border transition-all" :class="store.activeLayerId === layer.id ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/50'">
<div class="flex items-center gap-1 px-2 py-1.5 cursor-pointer" @click="handleSetActiveLayer(layer.id)">
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleCollapse(layer.id)">
<ChevronDown v-if="!layer.collapsed" class="h-3.5 w-3.5 text-muted-foreground" />
<ChevronRight v-else class="h-3.5 w-3.5 text-muted-foreground" />
</button>
<div class="w-3 h-3 rounded-full shrink-0" :style="{ backgroundColor: layer.color }" />
<template v-if="editingLayerId === layer.id">
<input ref="renameInputRef" v-model="editingName" class="flex-1 min-w-0 text-xs bg-background border border-border rounded px-1.5 py-0.5 outline-none focus:border-primary" @mousedown.stop @click.stop @keydown.enter="handleRenameLayer(layer.id)" @keydown.escape="cancelRename" />
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRenameLayer(layer.id)">
<Check class="h-3 w-3 text-green-500" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="cancelRename">
<X class="h-3 w-3 text-red-500" />
</button>
</template>
<template v-else>
<span class="flex-1 min-w-0 text-xs font-medium truncate cursor-pointer hover:bg-muted/50 rounded px-1" @dblclick.stop="startRename(layer.id)">{{ layer.name }}</span>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-0 hover:opacity-100" @click.stop="startRename(layer.id)">
<Edit3 class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleLayoutLock(layer.id)" :title="isLayerLocked(layer) ? t('diagram.layerUnlock') : t('diagram.layerLock')">
<Lock v-if="isLayerLocked(layer)" class="h-3 w-3 text-muted-foreground" />
<Unlock v-else class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleVisibility(layer.id)" :title="layer.visible ? t('diagram.hideLayer') : t('diagram.showLayer')">
<Eye v-if="layer.visible" class="h-3 w-3 text-muted-foreground" />
<EyeOff v-else class="h-3 w-3 text-muted-foreground/50" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRemoveLayer(layer.id)" :title="t('diagram.deleteLayer')">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</template>
</div>
<div v-if="!layer.collapsed" class="px-2 pb-2">
<div class="space-y-0.5">
<div v-for="table in getLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-muted/50">
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleRemoveTable(layer.id, table.name)" :title="t('diagram.removeFromLayer')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleDeleteTable(table.name)" :title="t('diagram.deleteLiveTable')">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
</div>
<div class="mt-2 space-y-1.5">
<div class="flex border-b border-border">
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'existing' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'existing')">
{{ t("diagram.tabSelectExisting") }}
</button>
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'create' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'create')">
{{ t("diagram.tabCreateTable") }}
</button>
</div>
<div v-if="getAddTableTab(layer.id) === 'existing'" class="space-y-1">
<div class="flex gap-1">
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="selectAllAvailableTables(layer.id)">
{{ t("diagram.selectAll") }}
</button>
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="clearSelection(layer.id)">
{{ t("diagram.clearSelection") }}
</button>
</div>
<input v-model="addTableFilter" type="search" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.filterTables')" @click.stop />
<div class="space-y-0.5 max-h-48 overflow-y-auto border border-border rounded">
<div v-for="table in filteredAvailableTables" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary shrink-0" />
<Square v-else class="h-3 w-3 text-muted-foreground shrink-0" />
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
</div>
<div v-if="filteredAvailableTables.length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
{{ t("diagram.noMatchingTables") }}
</div>
</div>
<div v-if="store.layers.length > 1" class="mt-1">
<div class="flex items-center justify-between mb-1 gap-1">
<button type="button" class="flex items-center gap-0.5 min-w-0 text-[9px] text-muted-foreground hover:text-foreground" @click.stop="toggleOtherLayersExpanded(layer.id)">
<ChevronDown v-if="isOtherLayersExpanded(layer.id)" class="h-3 w-3 shrink-0" />
<ChevronRight v-else class="h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.tablesInOtherLayers") }}</span>
</button>
<button type="button" class="text-[9px] text-primary/70 hover:text-primary shrink-0" @click.stop="selectAllTablesFromOtherLayers(layer.id)">
{{ t("diagram.selectAll") }}
</button>
</div>
<div v-if="isOtherLayersExpanded(layer.id)" class="space-y-0.5 max-h-32 overflow-y-auto border border-border rounded bg-muted/20">
<div v-for="table in getFilteredOtherLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<div class="relative shrink-0">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 text-muted-foreground" />
<div class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full border border-background" :style="{ backgroundColor: getLayerColor(store.layers.find((l) => l.id !== layer.id && l.tableNames.includes(table.name))?.id || '') }" />
</div>
<span class="flex-1 min-w-0 truncate opacity-70">{{ table.name }}</span>
<span class="text-[9px] px-1 py-0.5 rounded bg-muted-foreground/10 text-muted-foreground shrink-0">
{{ getOtherLayerForTable(table.name, layer.id) }}
</span>
</div>
<div v-if="getFilteredOtherLayerTables(layer.id).length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
{{ t("diagram.noMatchingTables") }}
</div>
</div>
</div>
<button type="button" class="w-full mt-1 text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" :disabled="getSelectedTables(layer.id).size === 0" @click.stop="handleAddSelectedTables(layer.id)">
{{ t("diagram.addSelectedCount", { count: getSelectedTables(layer.id).size }) }}
</button>
</div>
<div v-else class="space-y-1.5" @click.stop>
<input v-model="createNameByLayerId[layer.id]" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.tableName')" @keydown.enter="submitCreateDraft(layer.id)" />
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input v-model="createWithIdByLayerId[layer.id]" type="checkbox" />
{{ t("diagram.withDefaultIdPk") }}
</label>
<p v-if="createErrorByLayerId[layer.id]" class="text-[10px] text-destructive">{{ createErrorByLayerId[layer.id] }}</p>
<button type="button" class="w-full text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" @click.stop="submitCreateDraft(layer.id)">
{{ t("diagram.create") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Check, X, Trash2, Zap } from "@lucide/vue";
import type { InferredRelationship } from "@/types/diagram";
import { cardinalityPairFromChoice, type CardinalityChoice, type CardinalityPair } from "@/lib/diagram/cardinality";
const DEFAULT_CHOICE: CardinalityChoice = "many-to-one";
const { t } = useI18n();
const props = defineProps<{
relationships: InferredRelationship[];
conflicts: InferredRelationship[];
pending: InferredRelationship[];
confirmedIds: string[];
ignoredIds: string[];
}>();
const emit = defineEmits<{
(e: "confirm", payload: { id: string } & CardinalityPair): void;
(e: "ignore", id: string): void;
(e: "confirm-all", payload: Array<{ id: string } & CardinalityPair>): void;
(e: "ignore-all"): void;
(e: "clear-all"): void;
}>();
/** Per-row cardinality selection; defaults to N:1 */
const cardinalityById = reactive<Record<string, CardinalityChoice>>({});
watch(
() => props.relationships.map((r) => r.id).join(","),
() => {
for (const rel of props.relationships) {
if (!cardinalityById[rel.id]) {
cardinalityById[rel.id] = DEFAULT_CHOICE;
}
}
},
{ immediate: true },
);
const isConfirmed = (id: string) => props.confirmedIds.includes(id);
const isIgnored = (id: string) => props.ignoredIds.includes(id);
const confidenceBadge = (confidence: string) => {
if (confidence === "high") return { class: "bg-green-100 text-green-800", text: t("diagram.confidenceHigh") };
if (confidence === "medium") return { class: "bg-amber-100 text-amber-800", text: t("diagram.confidenceMedium") };
return { class: "bg-red-100 text-red-800", text: t("diagram.confidenceLow") };
};
const relationshipTitle = (relationship: InferredRelationship) => {
return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`;
};
const confidenceOrder = { high: 2, medium: 1, low: 0 };
const sortedRelationships = computed(() => {
return [...props.relationships].sort((a, b) => {
if (isConfirmed(a.id) !== isConfirmed(b.id)) return isConfirmed(a.id) ? -1 : 1;
if (isIgnored(a.id) !== isIgnored(b.id)) return isIgnored(a.id) ? 1 : -1;
return (confidenceOrder[b.confidence] || 0) - (confidenceOrder[a.confidence] || 0);
});
});
function choiceFor(id: string): CardinalityChoice {
return cardinalityById[id] ?? DEFAULT_CHOICE;
}
function emitConfirm(id: string) {
emit("confirm", { id, ...cardinalityPairFromChoice(choiceFor(id)) });
}
function emitConfirmAll() {
const payload = props.pending.map((rel) => ({
id: rel.id,
...cardinalityPairFromChoice(choiceFor(rel.id)),
}));
emit("confirm-all", payload);
}
</script>
<template>
<div class="flex h-full min-w-0 flex-col overflow-hidden">
<div class="shrink-0 border-b border-border px-3 py-2">
<p class="mb-2 text-[11px] text-muted-foreground">{{ t("diagram.matchPickCardinality") }}</p>
<div class="mb-2 flex min-w-0 items-center justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-1">
<Badge variant="secondary" class="h-5 px-2 text-xs"> {{ relationships.length }} {{ t("diagram.total") }} </Badge>
<Badge variant="default" class="h-5 px-2 text-xs"> {{ confirmedIds.length }} {{ t("diagram.confirmed") }} </Badge>
<Badge variant="outline" class="h-5 px-2 text-xs"> {{ pending.length }} {{ t("diagram.pending") }} </Badge>
<Badge v-if="conflicts.length > 0" variant="destructive" class="h-5 px-2 text-xs"> {{ conflicts.length }} {{ t("diagram.conflicts") }} </Badge>
</div>
</div>
<div class="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emitConfirmAll">
<Check class="mr-1 h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.confirmAll") }}</span>
</Button>
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emit('ignore-all')">
<X class="mr-1 h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.ignoreAll") }}</span>
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="confirmedIds.length === 0 && ignoredIds.length === 0" :title="t('diagram.clearAll')" @click="emit('clear-all')">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-2">
<div v-if="relationships.length === 0" class="flex flex-col items-center justify-center py-8 text-xs text-muted-foreground">
<Zap class="mb-2 h-5 w-5" />
<span>{{ t("diagram.noInferred") }}</span>
</div>
<div v-else class="flex min-w-0 flex-col gap-1">
<div
v-for="relationship in sortedRelationships"
:key="relationship.id"
class="flex min-w-0 items-stretch gap-2 rounded-md border p-2 text-xs transition-all hover:bg-muted/50"
:class="[
isConfirmed(relationship.id) ? 'border-primary/30 bg-primary/5' : '',
isIgnored(relationship.id) ? 'border-border opacity-50' : '',
props.conflicts.includes(relationship) && !isConfirmed(relationship.id) && !isIgnored(relationship.id) ? 'border-red-400/50 bg-red-50/50' : '',
!isConfirmed(relationship.id) && !isIgnored(relationship.id) && !props.conflicts.includes(relationship) ? 'border-border hover:border-muted-foreground/50' : '',
]"
:title="`${relationshipTitle(relationship)}\nConfidence: ${relationship.confidence}\nStrategy: ${relationship.strategy}`"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex min-w-0 items-center gap-1.5">
<Badge :class="confidenceBadge(relationship.confidence).class" class="h-4 shrink-0 px-1.5 text-[10px]">
{{ confidenceBadge(relationship.confidence).text }}
</Badge>
<span class="min-w-0 flex-1 truncate font-mono text-[11px]" :title="`${relationship.sourceTable}.${relationship.sourceColumn}`"> {{ relationship.sourceTable }}.{{ relationship.sourceColumn }} </span>
</div>
<Select v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" :model-value="choiceFor(relationship.id)" @update:model-value="(value: any) => (cardinalityById[relationship.id] = String(value) as CardinalityChoice)">
<SelectTrigger class="h-7 w-full text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
</SelectContent>
</Select>
<div v-else class="text-center text-[10px] text-muted-foreground">↓</div>
<span class="min-w-0 truncate font-mono text-[11px]" :title="`${relationship.targetTable}.${relationship.targetColumn}`"> {{ relationship.targetTable }}.{{ relationship.targetColumn }} </span>
</div>
<div v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" class="flex shrink-0 flex-col justify-between">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.ignoreMatch')" @click="emit('ignore', relationship.id)">
<X class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.confirmMatch')" @click="emitConfirm(relationship.id)">
<Check class="h-3.5 w-3.5 text-green-600" />
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed, inject, type Ref, type ComputedRef } from "vue";
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@vue-flow/core";
import type { DiagramRelationship } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY, EDGE_ROUTE_OFFSET, EDGE_STROKE_IDLE, EDGE_STROKE_HOVER } from "@/lib/diagram/diagram-constants";
import type { RelationshipEdgeData } from "@/lib/diagram/vue-flow-adapter";
import { alignWaypointsToEndpoints, endpointRectsFromObstacles, pathSkimsEndpoints, pointAlongPolyline, pointsToSvgPath, polylineLength, routeOrthogonalAroundObstacles, type ObstacleRect, type Point } from "@/lib/diagram/edge-obstacle-router";
const HOVER_BLUE = "#2563eb";
/** Prefer live obstacle routing when it is at most this fraction of aligned ELK path length. */
const LIVE_ROUTE_LENGTH_RATIO = 0.85;
/** Cardinality badge positions along the edge (arc-length fraction). */
const SOURCE_CARDINALITY_T = 0.18;
const TARGET_CARDINALITY_T = 0.82;
const props = defineProps<EdgeProps<RelationshipEdgeData>>();
const hoveredEdgeId = inject<Ref<string | null> | null>(DIAGRAM_HOVERED_EDGE_KEY, null);
const obstacles = inject<ComputedRef<ObstacleRect[]> | Ref<ObstacleRect[]> | null>(DIAGRAM_EDGE_OBSTACLES_KEY, null);
const isHovered = computed(() => hoveredEdgeId?.value === props.id);
function isDiagramRelationship(rel: DiagramRelationship | InferredRelationship): rel is DiagramRelationship {
return "kind" in rel;
}
function snapRoutedToHandles(points: Point[]): Point[] {
if (points.length < 2) return points;
const snapped = points.map((p) => ({ ...p }));
snapped[0] = { x: props.sourceX, y: props.sourceY };
snapped[snapped.length - 1] = { x: props.targetX, y: props.targetY };
return snapped;
}
function buildRoutedPoints(): Point[] | null {
const obstacleList = obstacles?.value ?? [];
const endpointIds: [string, string] = [props.source, props.target];
const endpointRects = endpointRectsFromObstacles(obstacleList, endpointIds);
const live = routeOrthogonalAroundObstacles({
source: { x: props.sourceX, y: props.sourceY },
target: { x: props.targetX, y: props.targetY },
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
obstacles: obstacleList,
endpointIds,
offset: EDGE_ROUTE_OFFSET,
});
const stored = props.data?.waypoints;
let aligned: Point[] | null = null;
if (stored?.length) {
aligned = alignWaypointsToEndpoints(stored, props.sourceX, props.sourceY, props.targetX, props.targetY, {
obstacles: obstacleList,
endpointIds,
});
// Drop ELK paths that skim endpoint table borders (align also rejects when obstacles present)
if (aligned?.length && pathSkimsEndpoints(aligned, endpointRects)) {
aligned = null;
}
}
if (aligned?.length && live?.length) {
const alignedLen = polylineLength(aligned);
const liveLen = polylineLength(live);
if (alignedLen > 0 && liveLen <= alignedLen * LIVE_ROUTE_LENGTH_RATIO) {
return snapRoutedToHandles(live);
}
return snapRoutedToHandles(aligned);
}
if (aligned?.length) return snapRoutedToHandles(aligned);
if (live?.length) return snapRoutedToHandles(live);
return null;
}
const pathResult = computed(() => {
const routed = buildRoutedPoints();
if (routed?.length) {
return {
path: pointsToSvgPath(routed),
points: routed,
};
}
const [path] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
borderRadius: 0,
offset: EDGE_ROUTE_OFFSET,
});
// Smooth-step fallback: approximate with endpoint → mid → endpoint for badge placement
const mid = {
x: (props.sourceX + props.targetX) / 2,
y: (props.sourceY + props.targetY) / 2,
};
return {
path,
points: [{ x: props.sourceX, y: props.sourceY }, mid, { x: props.targetX, y: props.targetY }],
};
});
const path = computed(() => pathResult.value.path);
const sourceBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, SOURCE_CARDINALITY_T));
const targetBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, TARGET_CARDINALITY_T));
const idleStroke = computed(() => {
const rel = props.data?.relationship;
if (!rel) {
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
}
if (isDiagramRelationship(rel)) {
if (rel.kind === "foreign-key") {
return "color-mix(in srgb, var(--primary) 55%, transparent)";
}
if (rel.kind === "custom") {
return "color-mix(in srgb, var(--primary) 70%, transparent)";
}
}
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
});
const strokeColor = computed(() => (isHovered.value ? HOVER_BLUE : idleStroke.value));
const strokeWidth = computed(() => (isHovered.value ? EDGE_STROKE_HOVER : EDGE_STROKE_IDLE));
const strokeDasharray = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && (rel.kind === "foreign-key" || rel.kind === "custom")) {
return "none";
}
return "5,5";
});
const sourceCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.sourceCardinality) return rel.sourceCardinality;
return "N";
});
const targetCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.targetCardinality) return rel.targetCardinality;
return "1";
});
const badgeClass = computed(() => (isHovered.value ? "border-blue-500 text-blue-600" : "border-border/80 text-foreground"));
</script>
<template>
<BaseEdge
:id="id"
:path="path"
:interaction-width="28"
:style="{
stroke: strokeColor,
strokeWidth: strokeWidth,
strokeDasharray: strokeDasharray === 'none' ? undefined : strokeDasharray,
}"
/>
<EdgeLabelRenderer>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${sourceBadgePos.x}px, ${sourceBadgePos.y}px)`,
}"
>
{{ sourceCardinality }}
</div>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${targetBadgePos.x}px, ${targetBadgePos.y}px)`,
}"
>
{{ targetCardinality }}
</div>
</EdgeLabelRenderer>
</template>
<style>
.diagram-flow .vue-flow__edge.relationship-edge,
.diagram-flow .vue-flow__edge.relationship-edge.inactive {
pointer-events: stroke !important;
cursor: pointer;
}
.diagram-flow .vue-flow__edge.relationship-edge .vue-flow__edge-interaction {
stroke: #000 !important;
stroke-opacity: 0 !important;
pointer-events: stroke !important;
}
.diagram-flow .vue-flow__edge.relationship-edge:hover .vue-flow__edge-path,
.diagram-flow .vue-flow__edge.relationship-edge.updating .vue-flow__edge-path {
stroke: #2563eb !important;
stroke-width: 3.5px !important;
}
.diagram-flow .vue-flow__node-layer {
pointer-events: none !important;
}
.diagram-flow .vue-flow__node-layer .layer-drag-handle {
pointer-events: auto !important;
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from "vue";
const emit = defineEmits<{
(e: "resize", delta: number): void;
}>();
const isResizing = ref(false);
const startX = ref(0);
function onMouseDown(e: MouseEvent) {
isResizing.value = true;
startX.value = e.clientX;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}
function onMouseMove(e: MouseEvent) {
if (!isResizing.value) return;
const delta = e.clientX - startX.value;
startX.value = e.clientX;
emit("resize", delta);
}
function onMouseUp() {
isResizing.value = false;
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
</script>
<template>
<div class="w-1 cursor-col-resize flex items-center justify-center hover:bg-muted/50 transition-colors group" :class="{ 'bg-muted': isResizing }" @mousedown="onMouseDown">
<div class="w-0.5 h-8 bg-border group-hover:bg-muted-foreground/50 transition-colors" />
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from "vue";
import { Handle, Position } from "@vue-flow/core";
import { Table2, KeyRound, Link2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import type { DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram";
import { isDraftTable, isDroppedColumn } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { useLayerStore } from "@/lib/diagram/layer-store";
import { CARD_WIDTH, COLUMN_TYPE_WIDTH, COLUMN_NAME_MAX_CHARS, COLUMN_TYPE_MAX_CHARS, TABLE_NAME_MAX_CHARS, EDGE_HANDLE_OUTSET } from "@/lib/diagram/diagram-constants";
const layerStore = useLayerStore();
const props = defineProps<{
data: {
table: DiagramTable;
relationships?: (DiagramRelationship | InferredRelationship)[];
};
selected?: boolean;
}>();
const emit = defineEmits<{
(e: "dblclick", event: MouseEvent): void;
}>();
const isDraft = computed(() => isDraftTable(props.data.table));
function visibleColumns(table: DiagramTable) {
return table.columns.filter((column) => !isDroppedColumn(table, column.name));
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function isRelationshipColumn(table: DiagramTable, columnName: string): boolean {
if (!props.data.relationships) return false;
return props.data.relationships.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName));
}
function truncateLabel(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(1, maxChars - 1))}…`;
}
const layerColor = computed(() => layerStore.getLayerColor(props.data.table.name));
const handleOffsetStyle = computed(() =>
EDGE_HANDLE_OUTSET > 0
? {
left: { left: `-${EDGE_HANDLE_OUTSET}px` },
right: { right: `-${EDGE_HANDLE_OUTSET}px` },
top: { top: `-${EDGE_HANDLE_OUTSET}px` },
bottom: { bottom: `-${EDGE_HANDLE_OUTSET}px` },
}
: { left: undefined, right: undefined, top: undefined, bottom: undefined },
);
</script>
<template>
<div class="relative rounded-md border bg-background shadow-sm" :class="selected ? 'border-primary ring-1 ring-primary/30' : 'border-border'" :style="{ width: `${CARD_WIDTH}px`, borderLeft: `3px solid ${layerColor}` }" @dblclick.stop="emit('dblclick', $event)">
<Handle id="left" type="source" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
<Handle id="left-target" type="target" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
<Handle id="right" type="source" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
<Handle id="right-target" type="target" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
<Handle id="top" type="source" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
<Handle id="top-target" type="target" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
<Handle id="bottom" type="source" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
<Handle id="bottom-target" type="target" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
<div class="overflow-hidden rounded-[inherit]">
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing">
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium" :title="data.table.name">
{{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
</span>
<Badge v-if="isDraft" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] border-amber-500/50 text-amber-700 dark:text-amber-400">Draft</Badge>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ visibleColumns(data.table).length }}</Badge>
</div>
<div>
<div v-for="column in visibleColumns(data.table)" :key="column.name" class="flex h-6 min-w-0 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0">
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Link2 v-else-if="isForeignKeyColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<Link2 v-else-if="isRelationshipColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono" :title="column.name">
{{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
</span>
<span class="shrink-0 truncate text-right text-[10px] text-muted-foreground" :style="{ width: `${COLUMN_TYPE_WIDTH}px` }" :title="column.data_type">
{{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useVueFlow } from "@vue-flow/core";
import { Button } from "@/components/ui/button";
import { ZoomIn, ZoomOut, Maximize2, RotateCcw, Undo2, Redo2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
defineProps<{
canUndo?: boolean;
canRedo?: boolean;
}>();
const emit = defineEmits<{
(e: "undo"): void;
(e: "redo"): void;
}>();
const { t } = useI18n();
const { zoomIn: vfZoomIn, zoomOut: vfZoomOut, fitView } = useVueFlow();
const FIT_OPTIONS = { padding: 0.15, duration: 150, minZoom: 0.05, maxZoom: 2 } as const;
function zoomIn() {
vfZoomIn({ duration: 150 });
}
function zoomOut() {
vfZoomOut({ duration: 150 });
}
function resetZoom() {
void fitView({ ...FIT_OPTIONS });
}
function fitToView() {
void fitView({ ...FIT_OPTIONS });
}
</script>
<template>
<div class="absolute bottom-3 left-3 z-50 flex flex-col items-center gap-1">
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.undo')" :disabled="!canUndo" @click="emit('undo')">
<Undo2 class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.redo')" :disabled="!canRedo" @click="emit('redo')">
<Redo2 class="h-3.5 w-3.5" />
</Button>
<div class="w-px h-1 bg-border my-1" />
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomIn')" @click="zoomIn">
<ZoomIn class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomOut')" @click="zoomOut">
<ZoomOut class="h-3.5 w-3.5" />
</Button>
<div class="w-px h-1 bg-border my-1" />
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.fitView')" @click="fitToView">
<Maximize2 class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.resetZoom')" @click="resetZoom">
<RotateCcw class="h-3.5 w-3.5" />
</Button>
</div>
</template>
@@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
const mocks = vi.hoisted(() => ({
buildDropTableSql: vi.fn(async (options: { schema?: string; tableName: string; cascade?: boolean }) => {
const qualifiedName = options.schema ? `"${options.schema}"."${options.tableName}"` : `"${options.tableName}"`;
return `DROP TABLE ${qualifiedName}${options.cascade ? " CASCADE" : ""};`;
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return {
Dialog: passthrough,
DialogContent: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
DialogTitle: passthrough,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
vi.mock("@/lib/backend/api", () => ({
buildCreateTableSql: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
executeBatch: vi.fn(),
}));
vi.mock("@/lib/database/dbAdminSql", () => ({
buildDropTableSql: mocks.buildDropTableSql,
supportsDropTableCascade: vi.fn(() => true),
}));
import DiagramSyncDialog from "../DiagramSyncDialog.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
mocks.buildDropTableSql.mockClear();
});
async function flushAsyncUpdates() {
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("DiagramSyncDialog", () => {
it("previews live table deletion without silently enabling CASCADE", async () => {
const liveTable: DiagramTable = {
name: "orders",
columns: [],
foreignKeys: [],
origin: "live",
pendingDrop: true,
};
const open = ref(false);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(DiagramSyncDialog, {
open: open.value,
tables: [liveTable],
connectionId: "connection-1",
database: "app",
schema: "public",
databaseType: "postgres",
"onUpdate:open": (value: boolean) => (open.value = value),
});
},
}),
);
app.mount(host);
mountedApps.push({ app, host });
open.value = true;
await flushAsyncUpdates();
expect(mocks.buildDropTableSql).toHaveBeenCalledWith({
databaseType: "postgres",
schema: "public",
tableName: "orders",
cascade: false,
});
expect(host.textContent).toContain('DROP TABLE "public"."orders";');
expect(host.textContent).not.toContain("CASCADE");
});
});
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import ZoomControls from "@/components/diagram/ZoomControls.vue";
const vueFlowMock = vi.hoisted(() => ({
zoomIn: vi.fn(),
zoomOut: vi.fn(),
fitView: vi.fn(async () => {}),
}));
vi.mock("@vue-flow/core", () => ({
useVueFlow: () => vueFlowMock,
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
const mountedApps: App[] = [];
function mountZoom(props: { canUndo?: boolean; canRedo?: boolean } = {}) {
const emits: string[] = [];
const container = document.createElement("div");
document.body.append(container);
const app = createApp(ZoomControls, {
canUndo: props.canUndo ?? true,
canRedo: props.canRedo ?? true,
onUndo: () => emits.push("undo"),
onRedo: () => emits.push("redo"),
});
mountedApps.push(app);
app.mount(container);
return { container, emits };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vueFlowMock.zoomIn.mockClear();
vueFlowMock.zoomOut.mockClear();
vueFlowMock.fitView.mockClear();
});
describe("ZoomControls", () => {
it("emits undo/redo and calls vue-flow zoom helpers", async () => {
const { container, emits } = mountZoom();
await nextTick();
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThanOrEqual(6);
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(emits).toEqual(["undo", "redo"]);
expect(vueFlowMock.zoomIn).toHaveBeenCalled();
expect(vueFlowMock.zoomOut).toHaveBeenCalled();
expect(vueFlowMock.fitView).toHaveBeenCalledTimes(2);
});
it("disables undo/redo when props are false", async () => {
const { container } = mountZoom({ canUndo: false, canRedo: false });
await nextTick();
const buttons = container.querySelectorAll("button");
expect((buttons[0] as HTMLButtonElement).disabled).toBe(true);
expect((buttons[1] as HTMLButtonElement).disabled).toBe(true);
});
});