mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
feat: add table structure editor
This commit is contained in:
+47
@@ -39,6 +39,7 @@ import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
|
||||
import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue";
|
||||
import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue";
|
||||
import TableImportDialog from "@/components/import/TableImportDialog.vue";
|
||||
import TableStructureEditorDialog from "@/components/structure/TableStructureEditorDialog.vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -124,6 +125,7 @@ const showSchemaDiffDialog = ref(false);
|
||||
const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showStructureEditorDialog = ref(false);
|
||||
const transferPrefillConnectionId = ref("");
|
||||
const transferPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
@@ -138,6 +140,10 @@ const tableImportPrefillConnectionId = ref("");
|
||||
const tableImportPrefillDatabase = ref("");
|
||||
const tableImportPrefillSchema = ref("");
|
||||
const tableImportPrefillTable = ref("");
|
||||
const structurePrefillConnectionId = ref("");
|
||||
const structurePrefillDatabase = ref("");
|
||||
const structurePrefillSchema = ref("");
|
||||
const structurePrefillTable = ref("");
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
@@ -214,6 +220,39 @@ watch(() => connectionStore.tableImportSource, (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => connectionStore.structureEditorSource, (v) => {
|
||||
if (v) {
|
||||
structurePrefillConnectionId.value = v.connectionId;
|
||||
structurePrefillDatabase.value = v.database;
|
||||
structurePrefillSchema.value = v.schema ?? "";
|
||||
structurePrefillTable.value = v.tableName;
|
||||
showStructureEditorDialog.value = true;
|
||||
connectionStore.structureEditorSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function onStructureEditorSaved() {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "data" && tab.tableMeta?.tableName === structurePrefillTable.value) {
|
||||
try {
|
||||
const columns = await api.getColumns(
|
||||
tab.connectionId,
|
||||
tab.database,
|
||||
tab.tableMeta.schema || tab.database,
|
||||
tab.tableMeta.tableName,
|
||||
);
|
||||
queryStore.setTableMeta(tab.id, {
|
||||
...tab.tableMeta,
|
||||
columns,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
});
|
||||
await onReloadData();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onConnectionConnectStarted(name: string) {
|
||||
toast(t("connection.connecting", { name }), 30000);
|
||||
}
|
||||
@@ -1281,6 +1320,14 @@ async function setupFileDrop() {
|
||||
:prefill-schema="tableImportPrefillSchema"
|
||||
:prefill-table="tableImportPrefillTable"
|
||||
/>
|
||||
<TableStructureEditorDialog
|
||||
v-model:open="showStructureEditorDialog"
|
||||
:prefill-connection-id="structurePrefillConnectionId"
|
||||
:prefill-database="structurePrefillDatabase"
|
||||
:prefill-schema="structurePrefillSchema"
|
||||
:prefill-table="structurePrefillTable"
|
||||
@saved="onStructureEditorSaved"
|
||||
/>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
@@ -44,6 +44,7 @@ const props = defineProps<{
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]);
|
||||
const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]);
|
||||
const isExportingDatabase = ref(false);
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
@@ -525,6 +526,17 @@ function openTableImport() {
|
||||
};
|
||||
}
|
||||
|
||||
function openStructureEditor() {
|
||||
const node = props.node;
|
||||
if (node.type !== "table" || !node.connectionId || !node.database) return;
|
||||
connectionStore.structureEditorSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
const canExpand = !leafTypes.has(props.node.type);
|
||||
const canPin = computed(() => pinnableTypes.has(props.node.type));
|
||||
const canOpenSqlFileExecution = computed(() => {
|
||||
@@ -539,6 +551,10 @@ const canOpenTableImport = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const canOpenStructureEditor = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableStructureSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
|
||||
const hasTypeMenu = computed(() => {
|
||||
const t = props.node.type;
|
||||
@@ -695,6 +711,9 @@ async function showMore() {
|
||||
<ContextMenuItem @click="openData">
|
||||
<TableProperties class="w-4 h-4" /> {{ t('contextMenu.viewData') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor">
|
||||
<PencilRuler class="w-4 h-4" /> {{ t('contextMenu.editStructure') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertTriangle, Check, Database, KeyRound, Loader2, Plus, RefreshCw, Save, TableProperties, Trash2, X,
|
||||
} from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildTableStructureChangeSql, type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { createColumnDrafts, createIndexDrafts, splitIndexColumns, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTable?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
|
||||
const activeTab = ref("columns");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const columns = ref<EditableStructureColumn[]>([]);
|
||||
const indexes = ref<EditableStructureIndex[]>([]);
|
||||
const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
||||
const triggers = ref<TriggerInfo[]>([]);
|
||||
|
||||
const connection = computed(() =>
|
||||
props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined
|
||||
);
|
||||
const databaseType = computed(() => connection.value?.db_type);
|
||||
const targetSchema = computed(() => props.prefillSchema || props.prefillDatabase || "");
|
||||
const targetLabel = computed(() => [
|
||||
connection.value?.name,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema,
|
||||
props.prefillTable,
|
||||
].filter(Boolean).join(" / "));
|
||||
|
||||
const changeSql = computed(() => buildTableStructureChangeSql({
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: props.prefillTable || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
}));
|
||||
const pendingStatements = computed(() => changeSql.value.statements);
|
||||
const warnings = computed(() => changeSql.value.warnings);
|
||||
const canApply = computed(() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.prefillConnectionId &&
|
||||
!!props.prefillTable
|
||||
);
|
||||
|
||||
function resetState() {
|
||||
activeTab.value = "columns";
|
||||
loading.value = false;
|
||||
saving.value = false;
|
||||
errorMessage.value = "";
|
||||
columns.value = [];
|
||||
indexes.value = [];
|
||||
foreignKeys.value = [];
|
||||
triggers.value = [];
|
||||
}
|
||||
|
||||
async function loadStructure() {
|
||||
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
const [nextColumns, nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([
|
||||
api.getColumns(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable),
|
||||
api.listIndexes(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable),
|
||||
api.listForeignKeys(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable),
|
||||
api.listTriggers(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable),
|
||||
]);
|
||||
columns.value = createColumnDrafts(nextColumns);
|
||||
indexes.value = createIndexDrafts(nextIndexes);
|
||||
foreignKeys.value = nextForeignKeys;
|
||||
triggers.value = nextTriggers;
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
columns.value.push({
|
||||
id: `new:${crypto.randomUUID()}`,
|
||||
name: "",
|
||||
dataType: "varchar(255)",
|
||||
isNullable: true,
|
||||
defaultValue: "",
|
||||
comment: "",
|
||||
isPrimaryKey: false,
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
|
||||
function removeNewColumn(column: EditableStructureColumn) {
|
||||
columns.value = columns.value.filter((item) => item.id !== column.id);
|
||||
}
|
||||
|
||||
function toggleDropColumn(column: EditableStructureColumn) {
|
||||
if (!column.original || column.isPrimaryKey) return;
|
||||
column.markedForDrop = !column.markedForDrop;
|
||||
}
|
||||
|
||||
function addIndex() {
|
||||
indexes.value.push({
|
||||
id: `new:${crypto.randomUUID()}`,
|
||||
name: "",
|
||||
columns: [],
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
|
||||
function updateIndexColumns(index: EditableStructureIndex, value: string) {
|
||||
index.columns = splitIndexColumns(value);
|
||||
}
|
||||
|
||||
function removeNewIndex(index: EditableStructureIndex) {
|
||||
indexes.value = indexes.value.filter((item) => item.id !== index.id);
|
||||
}
|
||||
|
||||
function toggleDropIndex(index: EditableStructureIndex) {
|
||||
if (!index.original || index.isPrimary) return;
|
||||
index.markedForDrop = !index.markedForDrop;
|
||||
}
|
||||
|
||||
async function applyChanges() {
|
||||
if (!canApply.value || !props.prefillConnectionId || !props.prefillDatabase) return;
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.executeBatch(props.prefillConnectionId, props.prefillDatabase, pendingStatements.value);
|
||||
toast(t("structureEditor.saved"), 2500);
|
||||
emit("saved");
|
||||
await loadStructure();
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
resetState();
|
||||
void loadStructure();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogScrollContent class="sm:max-w-[1180px]" :trap-focus="false" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<TableProperties class="h-4 w-4" />
|
||||
{{ t('structureEditor.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-3 py-2">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
|
||||
<Database class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t('editor.noDatabase') }}</span>
|
||||
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
|
||||
<Button variant="ghost" size="sm" class="h-7 gap-1" :disabled="loading || saving" @click="loadStructure">
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.refresh') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex h-[420px] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid min-h-[520px] grid-cols-[minmax(0,1fr)_360px] gap-3">
|
||||
<div class="min-w-0 rounded-md border">
|
||||
<Tabs v-model="activeTab" class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="columns">{{ t('structureEditor.columns') }}</TabsTrigger>
|
||||
<TabsTrigger value="indexes">{{ t('structureEditor.indexes') }}</TabsTrigger>
|
||||
<TabsTrigger value="foreignKeys">{{ t('structureEditor.foreignKeys') }}</TabsTrigger>
|
||||
<TabsTrigger value="triggers">{{ t('structureEditor.triggers') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button v-if="activeTab === 'columns'" size="sm" class="h-7 gap-1" @click="addColumn">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.addColumn') }}
|
||||
</Button>
|
||||
<Button v-if="activeTab === 'indexes'" size="sm" class="h-7 gap-1" @click="addIndex">
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.addIndex') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="columns" class="m-0 min-h-0 flex-1 overflow-auto p-0">
|
||||
<table class="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th class="w-8 border-b border-r px-2 py-2 text-left">#</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.columnName') }}</th>
|
||||
<th class="min-w-40 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.dataType') }}</th>
|
||||
<th class="w-20 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.nullable') }}</th>
|
||||
<th class="min-w-32 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.defaultValue') }}</th>
|
||||
<th class="min-w-36 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.comment') }}</th>
|
||||
<th class="w-24 border-b px-2 py-2 text-left">{{ t('structureEditor.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(column, index) in columns"
|
||||
:key="column.id"
|
||||
:class="column.markedForDrop ? 'bg-destructive/5 opacity-60' : ''"
|
||||
>
|
||||
<td class="border-b border-r px-2 py-1.5 text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<KeyRound v-if="column.isPrimaryKey" class="h-3 w-3 text-amber-500" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.name" class="h-7 min-w-32 text-xs" :disabled="column.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.dataType" class="h-7 min-w-36 font-mono text-xs" :disabled="column.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="column.isNullable" type="checkbox" class="h-3.5 w-3.5" :disabled="column.markedForDrop || column.isPrimaryKey" />
|
||||
<span>{{ column.isNullable ? t('structureEditor.yes') : t('structureEditor.no') }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.defaultValue" class="h-7 min-w-28 font-mono text-xs" :disabled="column.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="column.comment" class="h-7 min-w-32 text-xs" :disabled="column.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b px-2 py-1.5">
|
||||
<Button
|
||||
v-if="column.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 gap-1"
|
||||
:disabled="column.isPrimaryKey"
|
||||
@click="toggleDropColumn(column)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop') }}
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewColumn(column)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.remove') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="indexes" class="m-0 min-h-0 flex-1 overflow-auto p-0">
|
||||
<table class="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th class="min-w-40 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.indexName') }}</th>
|
||||
<th class="min-w-60 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.indexColumns') }}</th>
|
||||
<th class="w-20 border-b border-r px-2 py-2 text-left">{{ t('structureEditor.unique') }}</th>
|
||||
<th class="w-24 border-b px-2 py-2 text-left">{{ t('structureEditor.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input v-model="index.name" class="h-7 min-w-36 text-xs" :disabled="!!index.original || index.markedForDrop" />
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<Input
|
||||
:model-value="toColumnNames(index.columns)"
|
||||
class="h-7 min-w-56 font-mono text-xs"
|
||||
:disabled="!!index.original || index.markedForDrop"
|
||||
@update:model-value="(value: any) => updateIndexColumns(index, String(value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-2 py-1.5">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="index.isUnique" type="checkbox" class="h-3.5 w-3.5" :disabled="!!index.original || index.markedForDrop" />
|
||||
<span>{{ index.isUnique ? t('structureEditor.yes') : t('structureEditor.no') }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b px-2 py-1.5">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t('structureEditor.primary') }}</Badge>
|
||||
<Button
|
||||
v-else-if="index.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 gap-1"
|
||||
@click="toggleDropIndex(index)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
{{ index.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop') }}
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="sm" class="h-7 gap-1" @click="removeNewIndex(index)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.remove') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="foreignKeys" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="foreignKeys.length === 0" class="py-10 text-center text-sm text-muted-foreground">{{ t('structureEditor.emptyReadonly') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-3 py-2 text-xs">
|
||||
<div class="font-medium">{{ fk.name }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triggers" class="m-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="triggers.length === 0" class="py-10 text-center text-sm text-muted-foreground">{{ t('structureEditor.emptyReadonly') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="trigger in triggers" :key="trigger.name" class="rounded-md border px-3 py-2 text-xs">
|
||||
<div class="font-medium">{{ trigger.name }}</div>
|
||||
<div class="mt-1 font-mono text-muted-foreground">{{ trigger.timing }} {{ trigger.event }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col rounded-md border">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
|
||||
<span>{{ t('structureEditor.sqlPreview') }}</span>
|
||||
<Badge variant="secondary">{{ pendingStatements.length }}</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3">
|
||||
<div v-if="warnings.length" class="mb-3 space-y-1">
|
||||
<div v-for="warning in warnings" :key="warning" class="flex gap-2 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-if="pendingStatements.length" class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ pendingStatements.join('\n') }}</pre>
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t('structureEditor.noChanges') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="saving" @click="open = false">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('dangerDialog.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="!canApply" @click="applyChanges">
|
||||
<Loader2 v-if="saving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Save v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.apply') }}
|
||||
</Button>
|
||||
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-8">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{{ t('structureEditor.ready') }}
|
||||
</Badge>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -216,6 +216,7 @@ export default {
|
||||
editConnection: "Edit Connection",
|
||||
newQuery: "New Query",
|
||||
viewData: "View Data",
|
||||
editStructure: "Edit Structure",
|
||||
refreshChildren: "Refresh",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
@@ -236,6 +237,37 @@ export default {
|
||||
foreignKeys: "Foreign Keys",
|
||||
triggers: "Triggers",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "Edit Table Structure",
|
||||
refresh: "Refresh Structure",
|
||||
columns: "Columns",
|
||||
indexes: "Indexes",
|
||||
foreignKeys: "Foreign Keys",
|
||||
triggers: "Triggers",
|
||||
addColumn: "Add Column",
|
||||
addIndex: "Add Index",
|
||||
columnName: "Column",
|
||||
dataType: "Type",
|
||||
nullable: "Nullable",
|
||||
defaultValue: "Default",
|
||||
comment: "Comment",
|
||||
actions: "Actions",
|
||||
indexName: "Index",
|
||||
indexColumns: "Columns",
|
||||
unique: "Unique",
|
||||
primary: "Primary",
|
||||
drop: "Drop",
|
||||
restore: "Restore",
|
||||
remove: "Remove",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
emptyReadonly: "No records",
|
||||
sqlPreview: "SQL Preview",
|
||||
noChanges: "No changes",
|
||||
apply: "Apply Changes",
|
||||
ready: "Ready",
|
||||
saved: "Table structure updated",
|
||||
},
|
||||
diagram: {
|
||||
title: "Relationship Diagram",
|
||||
open: "View Diagram",
|
||||
|
||||
@@ -216,6 +216,7 @@ export default {
|
||||
editConnection: "编辑连接",
|
||||
newQuery: "新建查询",
|
||||
viewData: "查看数据",
|
||||
editStructure: "编辑表结构",
|
||||
refreshChildren: "刷新",
|
||||
pin: "置顶",
|
||||
unpin: "取消置顶",
|
||||
@@ -236,6 +237,37 @@ export default {
|
||||
foreignKeys: "外键",
|
||||
triggers: "触发器",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "编辑表结构",
|
||||
refresh: "刷新结构",
|
||||
columns: "字段",
|
||||
indexes: "索引",
|
||||
foreignKeys: "外键",
|
||||
triggers: "触发器",
|
||||
addColumn: "新增字段",
|
||||
addIndex: "新增索引",
|
||||
columnName: "字段名",
|
||||
dataType: "类型",
|
||||
nullable: "可为空",
|
||||
defaultValue: "默认值",
|
||||
comment: "注释",
|
||||
actions: "操作",
|
||||
indexName: "索引名",
|
||||
indexColumns: "字段列表",
|
||||
unique: "唯一",
|
||||
primary: "主键",
|
||||
drop: "删除",
|
||||
restore: "恢复",
|
||||
remove: "移除",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
emptyReadonly: "暂无记录",
|
||||
sqlPreview: "SQL 预览",
|
||||
noChanges: "暂无变更",
|
||||
apply: "应用变更",
|
||||
ready: "可执行",
|
||||
saved: "表结构已更新",
|
||||
},
|
||||
diagram: {
|
||||
title: "关系图",
|
||||
open: "查看关系图",
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { ColumnInfo, DatabaseType, IndexInfo } from "../types/database.ts";
|
||||
|
||||
export interface EditableStructureColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
defaultValue: string;
|
||||
comment: string;
|
||||
isPrimaryKey: boolean;
|
||||
original?: ColumnInfo;
|
||||
markedForDrop: boolean;
|
||||
}
|
||||
|
||||
export interface EditableStructureIndex {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: string[];
|
||||
isUnique: boolean;
|
||||
isPrimary: boolean;
|
||||
original?: IndexInfo;
|
||||
markedForDrop: boolean;
|
||||
}
|
||||
|
||||
export interface BuildTableStructureChangeSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columns: EditableStructureColumn[];
|
||||
indexes: EditableStructureIndex[];
|
||||
}
|
||||
|
||||
export interface TableStructureChangeSql {
|
||||
statements: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function qualifiedTable(databaseType: DatabaseType | undefined, schema: string | undefined, tableName: string): string {
|
||||
if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) {
|
||||
return `${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, tableName)}`;
|
||||
}
|
||||
return quoteIdent(databaseType, tableName);
|
||||
}
|
||||
|
||||
function quoteString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function clean(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeDefault(value: string | null | undefined): string {
|
||||
const trimmed = clean(value);
|
||||
return trimmed.toLowerCase() === "null" ? "" : trimmed;
|
||||
}
|
||||
|
||||
function columnDefinition(databaseType: DatabaseType | undefined, column: EditableStructureColumn): string {
|
||||
const parts = [
|
||||
quoteIdent(databaseType, column.name),
|
||||
column.dataType.trim(),
|
||||
];
|
||||
if (!column.isNullable) parts.push("NOT NULL");
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
|
||||
if (databaseType === "mysql" && clean(column.comment)) {
|
||||
parts.push(`COMMENT ${quoteString(clean(column.comment))}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function originalDefault(column: EditableStructureColumn): string {
|
||||
return normalizeDefault(column.original?.column_default);
|
||||
}
|
||||
|
||||
function originalComment(column: EditableStructureColumn): string {
|
||||
return clean(column.original?.comment);
|
||||
}
|
||||
|
||||
function hasExistingColumnAttributeChange(column: EditableStructureColumn): boolean {
|
||||
const original = column.original;
|
||||
if (!original) return false;
|
||||
return (
|
||||
column.name !== original.name ||
|
||||
column.dataType.trim() !== original.data_type.trim() ||
|
||||
column.isNullable !== original.is_nullable ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column) ||
|
||||
clean(column.comment) !== originalComment(column)
|
||||
);
|
||||
}
|
||||
|
||||
function buildAddColumnSql(
|
||||
databaseType: DatabaseType | undefined,
|
||||
table: string,
|
||||
column: EditableStructureColumn,
|
||||
): string[] {
|
||||
const addKeyword = databaseType === "sqlserver" ? "ADD" : "ADD COLUMN";
|
||||
const statements = [
|
||||
`ALTER TABLE ${table} ${addKeyword} ${columnDefinition(databaseType, column)};`,
|
||||
];
|
||||
if (databaseType === "postgres" && clean(column.comment)) {
|
||||
statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn): string[] {
|
||||
const originalName = column.original?.name ?? column.name;
|
||||
const operation = column.name === originalName
|
||||
? `MODIFY COLUMN ${columnDefinition("mysql", column)}`
|
||||
: `CHANGE COLUMN ${quoteIdent("mysql", originalName)} ${columnDefinition("mysql", column)}`;
|
||||
return [`ALTER TABLE ${table} ${operation};`];
|
||||
}
|
||||
|
||||
function buildPostgresExistingColumnSql(table: string, column: EditableStructureColumn): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
const currentName = column.name;
|
||||
if (column.name !== original.name) {
|
||||
statements.push(`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("postgres", original.name)} TO ${quoteIdent("postgres", column.name)};`);
|
||||
}
|
||||
if (column.dataType.trim() !== original.data_type.trim()) {
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} TYPE ${column.dataType.trim()};`);
|
||||
}
|
||||
if (column.isNullable !== original.is_nullable) {
|
||||
const action = column.isNullable ? "DROP NOT NULL" : "SET NOT NULL";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`);
|
||||
}
|
||||
if (normalizeDefault(column.defaultValue) !== originalDefault(column)) {
|
||||
const defaultValue = normalizeDefault(column.defaultValue);
|
||||
const action = defaultValue ? `SET DEFAULT ${defaultValue}` : "DROP DEFAULT";
|
||||
statements.push(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent("postgres", currentName)} ${action};`);
|
||||
}
|
||||
if (clean(column.comment) !== originalComment(column)) {
|
||||
const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL";
|
||||
statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent("postgres", currentName)} IS ${commentValue};`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildSqliteExistingColumnSql(
|
||||
table: string,
|
||||
column: EditableStructureColumn,
|
||||
warnings: string[],
|
||||
): string[] {
|
||||
const original = column.original;
|
||||
if (!original) return [];
|
||||
|
||||
const statements: string[] = [];
|
||||
const unsupportedChange = (
|
||||
column.dataType.trim() !== original.data_type.trim() ||
|
||||
column.isNullable !== original.is_nullable ||
|
||||
normalizeDefault(column.defaultValue) !== originalDefault(column) ||
|
||||
clean(column.comment) !== originalComment(column)
|
||||
);
|
||||
if (column.name !== original.name) {
|
||||
statements.push(`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("sqlite", original.name)} TO ${quoteIdent("sqlite", column.name)};`);
|
||||
}
|
||||
if (unsupportedChange) {
|
||||
warnings.push(`SQLite cannot safely alter existing column "${original.name}" without rebuilding the table.`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildColumnSql(
|
||||
options: BuildTableStructureChangeSqlOptions,
|
||||
warnings: string[],
|
||||
): string[] {
|
||||
const databaseType = options.databaseType;
|
||||
const table = qualifiedTable(databaseType, options.schema, options.tableName);
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const column of options.columns) {
|
||||
if (column.markedForDrop) {
|
||||
if (!column.original) continue;
|
||||
if (column.original.is_primary_key) {
|
||||
warnings.push(`Primary key column "${column.original.name}" cannot be dropped from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(`ALTER TABLE ${table} DROP COLUMN ${quoteIdent(databaseType, column.original.name)};`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column.original) {
|
||||
statements.push(...buildAddColumnSql(databaseType, table, column));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasExistingColumnAttributeChange(column)) continue;
|
||||
if (databaseType === "mysql") {
|
||||
statements.push(...buildMysqlExistingColumnSql(table, column));
|
||||
} else if (databaseType === "postgres") {
|
||||
statements.push(...buildPostgresExistingColumnSql(table, column));
|
||||
} else if (databaseType === "sqlite") {
|
||||
statements.push(...buildSqliteExistingColumnSql(table, column, warnings));
|
||||
} else {
|
||||
warnings.push(`Editing existing columns is not supported for ${databaseType ?? "this database"} yet.`);
|
||||
}
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
function buildDropIndexSql(
|
||||
databaseType: DatabaseType | undefined,
|
||||
table: string,
|
||||
schema: string | undefined,
|
||||
indexName: string,
|
||||
): string {
|
||||
if (databaseType === "mysql") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`;
|
||||
if (databaseType === "sqlserver") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`;
|
||||
if ((databaseType === "postgres" || databaseType === "oracle") && schema) {
|
||||
return `DROP INDEX ${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, indexName)};`;
|
||||
}
|
||||
return `DROP INDEX ${quoteIdent(databaseType, indexName)};`;
|
||||
}
|
||||
|
||||
function buildIndexSql(options: BuildTableStructureChangeSqlOptions, warnings: string[]): string[] {
|
||||
const databaseType = options.databaseType;
|
||||
const table = qualifiedTable(databaseType, options.schema, options.tableName);
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const index of options.indexes) {
|
||||
if (index.markedForDrop) {
|
||||
if (!index.original) continue;
|
||||
if (index.original.is_primary) {
|
||||
warnings.push(`Primary index "${index.original.name}" cannot be dropped from this editor.`);
|
||||
continue;
|
||||
}
|
||||
statements.push(buildDropIndexSql(databaseType, table, options.schema, index.original.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index.original) continue;
|
||||
const name = clean(index.name);
|
||||
const columns = index.columns.map(clean).filter(Boolean);
|
||||
if (!name || columns.length === 0) continue;
|
||||
const unique = index.isUnique ? "UNIQUE " : "";
|
||||
const cols = columns.map((column) => quoteIdent(databaseType, column)).join(", ");
|
||||
statements.push(`CREATE ${unique}INDEX ${quoteIdent(databaseType, name)} ON ${table} (${cols});`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
function validateDraft(options: BuildTableStructureChangeSqlOptions): string[] {
|
||||
const warnings: string[] = [];
|
||||
const activeColumns = options.columns.filter((column) => !column.markedForDrop);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const column of activeColumns) {
|
||||
if (!clean(column.name)) warnings.push("Column name cannot be empty.");
|
||||
if (!clean(column.dataType)) warnings.push(`Column "${column.name || "(new)"}" type cannot be empty.`);
|
||||
const key = clean(column.name).toLowerCase();
|
||||
if (key && names.has(key)) warnings.push(`Column "${column.name}" is duplicated.`);
|
||||
if (key) names.add(key);
|
||||
}
|
||||
|
||||
for (const index of options.indexes.filter((idx) => !idx.markedForDrop && !idx.original)) {
|
||||
if (!clean(index.name)) warnings.push("Index name cannot be empty.");
|
||||
if (index.columns.map(clean).filter(Boolean).length === 0) {
|
||||
warnings.push(`Index "${index.name || "(new)"}" needs at least one column.`);
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function buildTableStructureChangeSql(options: BuildTableStructureChangeSqlOptions): TableStructureChangeSql {
|
||||
const warnings = validateDraft(options);
|
||||
const statements = [
|
||||
...buildColumnSql(options, warnings),
|
||||
...buildIndexSql(options, warnings),
|
||||
];
|
||||
|
||||
return { statements, warnings };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ColumnInfo, IndexInfo } from "../types/database.ts";
|
||||
import type { EditableStructureColumn, EditableStructureIndex } from "./tableStructureEditorSql.ts";
|
||||
|
||||
export function createColumnDrafts(columns: ColumnInfo[]): EditableStructureColumn[] {
|
||||
return columns.map((column) => ({
|
||||
id: `existing:${column.name}`,
|
||||
name: column.name,
|
||||
dataType: column.data_type,
|
||||
isNullable: column.is_nullable,
|
||||
defaultValue: column.column_default ?? "",
|
||||
comment: column.comment ?? "",
|
||||
isPrimaryKey: column.is_primary_key,
|
||||
original: column,
|
||||
markedForDrop: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[] {
|
||||
return indexes.map((index) => ({
|
||||
id: `existing:${index.name}`,
|
||||
name: index.name,
|
||||
columns: [...index.columns],
|
||||
isUnique: index.is_unique,
|
||||
isPrimary: index.is_primary,
|
||||
original: index,
|
||||
markedForDrop: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function splitIndexColumns(value: string): string[] {
|
||||
return value
|
||||
.split(/[,\s]+/g)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function toColumnNames(columns: string[]): string {
|
||||
return columns.join(", ");
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null);
|
||||
const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const structureEditorSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
@@ -659,5 +660,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
sqlFileSource,
|
||||
diagramSource,
|
||||
tableImportSource,
|
||||
structureEditorSource,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user