refactor: convert table structure editor from modal dialog to tab
Replace the dialog-based TableStructureEditorDialog with a tab-based TableStructureEditor, making it consistent with query editors, data viewers, and object browsers. Users can now switch between editing table structure and running queries freely.
This commit is contained in:
parent
72d5435535
commit
801434e423
|
|
@ -918,6 +918,16 @@ onUnmounted(() => {
|
|||
})
|
||||
"
|
||||
@object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)"
|
||||
@structure-editor-saved="
|
||||
activeTab &&
|
||||
onStructureEditorSaved(onReloadData, toast, {
|
||||
connectionId: activeTab.connectionId,
|
||||
database: activeTab.database,
|
||||
schema: activeTab.schema,
|
||||
tableName: activeTab.structureTableName || '',
|
||||
})
|
||||
"
|
||||
@structure-editor-close="activeTab && queryStore.closeTab(activeTab.id)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
|
@ -1000,7 +1010,6 @@ onUnmounted(() => {
|
|||
setConnectionDialogOpen(false);
|
||||
showDriverStore = true;
|
||||
"
|
||||
@structure-editor-saved="onStructureEditorSaved(onReloadData, toast)"
|
||||
@open-lineage-target="openLineageTarget"
|
||||
@open-database-search-target="openDatabaseSearchTarget"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,6 @@ const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/D
|
|||
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
|
||||
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const TableStructureEditorDialog = defineAsyncComponent(
|
||||
() => import("@/components/structure/TableStructureEditorDialog.vue"),
|
||||
);
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
|
||||
|
|
@ -42,7 +39,6 @@ const emit = defineEmits<{
|
|||
connectSucceeded: [name: string];
|
||||
connectFailed: [message: string];
|
||||
openDriverStore: [];
|
||||
structureEditorSaved: [];
|
||||
openLineageTarget: [
|
||||
target: {
|
||||
connectionId: string;
|
||||
|
|
@ -165,15 +161,6 @@ watch(
|
|||
:prefill-schema="dialogs.tableImportPrefillSchema.value"
|
||||
:prefill-table="dialogs.tableImportPrefillTable.value"
|
||||
/>
|
||||
<TableStructureEditorDialog
|
||||
v-if="dialogs.showStructureEditorDialog.value"
|
||||
v-model:open="dialogs.showStructureEditorDialog.value"
|
||||
:prefill-connection-id="dialogs.structurePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.structurePrefillDatabase.value"
|
||||
:prefill-schema="dialogs.structurePrefillSchema.value"
|
||||
:prefill-table="dialogs.structurePrefillTable.value"
|
||||
@saved="emit('structureEditorSaved')"
|
||||
/>
|
||||
<FieldLineageDialog
|
||||
v-if="dialogs.showFieldLineageDialog.value"
|
||||
v-model:open="dialogs.showFieldLineageDialog.value"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, Package, Check } from "lucide-vue-next";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, Package, Check } from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -120,7 +120,7 @@ function tabColorStyle(tab: QueryTab) {
|
|||
}
|
||||
|
||||
function tabIconClass(tab: QueryTab) {
|
||||
if (tab.mode === "data" || tab.mode === "objects") return "text-emerald-600 dark:text-emerald-400";
|
||||
if (tab.mode === "data" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +205,7 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.v
|
|||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
|
@ -82,6 +83,8 @@ const emit = defineEmits<{
|
|||
clickTable: [tableName: string];
|
||||
openObjectTable: [target: { tableName: string; schema?: string }];
|
||||
objectSchemaChange: [schema: string | undefined];
|
||||
structureEditorSaved: [];
|
||||
structureEditorClose: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -678,5 +681,18 @@ defineExpose({ focusSearch, refreshData });
|
|||
@schema-change="emit('objectSchemaChange', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Structure mode: table structure editor -->
|
||||
<template v-else-if="activeTab.mode === 'structure'">
|
||||
<TableStructureEditor
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:schema="activeTab.schema"
|
||||
:table-name="activeTab.structureTableName || ''"
|
||||
@saved="emit('structureEditorSaved')"
|
||||
@close="emit('structureEditorClose')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -511,12 +511,7 @@ function openViewData(row: ObjectBrowserRow) {
|
|||
|
||||
function openStructureEditor(row: ObjectBrowserRow) {
|
||||
if (row.type !== "TABLE") return;
|
||||
connectionStore.structureEditorSource = {
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
};
|
||||
queryStore.openTableStructure(props.connection.id, props.database, row.schema || selectedSchema.value, row.name);
|
||||
}
|
||||
|
||||
function openDiagram(row: ObjectBrowserRow) {
|
||||
|
|
|
|||
|
|
@ -1240,12 +1240,7 @@ async function confirmDuplicateStructure() {
|
|||
function createTable() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
connectionStore.structureEditorSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.schema,
|
||||
tableName: "",
|
||||
};
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, "");
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
|
|
@ -1491,12 +1486,7 @@ 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,
|
||||
};
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.label);
|
||||
}
|
||||
|
||||
function openFieldLineage() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,918 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Database,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
|
||||
import {
|
||||
buildStructureTargetLabel,
|
||||
createColumnDrafts,
|
||||
createIndexDrafts,
|
||||
toColumnNames,
|
||||
} from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const activeTab = ref("columns");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sqlPreviewLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const columns = ref<EditableStructureColumn[]>([]);
|
||||
const indexes = ref<EditableStructureIndex[]>([]);
|
||||
const pendingStatements = ref<string[]>([]);
|
||||
const warnings = ref<string[]>([]);
|
||||
const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
||||
const triggers = ref<TriggerInfo[]>([]);
|
||||
|
||||
const indexColWidths = ref([132, 200, 64, 96, 132, 160, 132, 76]);
|
||||
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
|
||||
|
||||
function onIndexColResize(e: MouseEvent, col: number) {
|
||||
e.preventDefault();
|
||||
resizing.value = { col, startX: e.clientX, startW: indexColWidths.value[col] };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!resizing.value) return;
|
||||
const delta = ev.clientX - resizing.value.startX;
|
||||
indexColWidths.value[col] = Math.max(60, resizing.value.startW + delta);
|
||||
};
|
||||
const onUp = () => {
|
||||
resizing.value = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
const connection = computed(() => (props.connectionId ? store.getConfig(props.connectionId) : undefined));
|
||||
const databaseType = computed(() => connection.value?.db_type);
|
||||
const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value));
|
||||
|
||||
const indexTypesByDb: Record<string, string[]> = {
|
||||
postgres: ["BTREE", "HASH", "GIST", "SPGIST", "GIN", "BRIN"],
|
||||
mysql: ["BTREE", "HASH", "FULLTEXT", "SPATIAL", "RTREE"],
|
||||
sqlserver: ["CLUSTERED", "NONCLUSTERED", "COLUMNSTORE", "NONCLUSTERED COLUMNSTORE", "XML", "SPATIAL"],
|
||||
oracle: ["NORMAL", "BITMAP", "FUNCTION-BASED NORMAL", "FUNCTION-BASED DOMAIN", "DOMAIN", "CLUSTER"],
|
||||
sqlite: ["BTREE"],
|
||||
};
|
||||
const indexTypeOptions = computed(() =>
|
||||
structureCapabilities.value.indexType ? (indexTypesByDb[databaseType.value ?? ""] ?? []) : [],
|
||||
);
|
||||
|
||||
const indexColLabels = computed(() => [
|
||||
t("structureEditor.indexName"),
|
||||
t("structureEditor.indexColumns"),
|
||||
t("structureEditor.unique"),
|
||||
t("structureEditor.indexType"),
|
||||
t("structureEditor.includedColumns"),
|
||||
t("structureEditor.filter"),
|
||||
t("structureEditor.comment"),
|
||||
t("structureEditor.actions"),
|
||||
]);
|
||||
const targetSchema = computed(() => props.schema || props.database || "");
|
||||
const isCreateMode = computed(() => !props.tableName);
|
||||
const newTableName = ref("");
|
||||
const targetLabel = computed(() =>
|
||||
buildStructureTargetLabel(
|
||||
connection.value?.name,
|
||||
props.database,
|
||||
props.schema,
|
||||
isCreateMode.value ? undefined : props.tableName,
|
||||
),
|
||||
);
|
||||
|
||||
let sqlPreviewRequestId = 0;
|
||||
|
||||
async function refreshSqlPreview() {
|
||||
const requestId = ++sqlPreviewRequestId;
|
||||
sqlPreviewLoading.value = true;
|
||||
const options = {
|
||||
databaseType: databaseType.value,
|
||||
schema: props.schema,
|
||||
tableName: isCreateMode.value ? newTableName.value : props.tableName || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
};
|
||||
try {
|
||||
const result = isCreateMode.value
|
||||
? await api.buildCreateTableSql(options)
|
||||
: await api.buildTableStructureChangeSql(options);
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = result.statements;
|
||||
warnings.value = result.warnings;
|
||||
} catch (e: any) {
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [e?.message || String(e)];
|
||||
} finally {
|
||||
if (requestId === sqlPreviewRequestId) sqlPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canApply = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
!sqlPreviewLoading.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.connectionId &&
|
||||
(isCreateMode.value ? !!newTableName.value.trim() : !!props.tableName),
|
||||
);
|
||||
|
||||
function resetState() {
|
||||
activeTab.value = "columns";
|
||||
loading.value = false;
|
||||
saving.value = false;
|
||||
sqlPreviewLoading.value = false;
|
||||
errorMessage.value = "";
|
||||
columns.value = [];
|
||||
indexes.value = [];
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
foreignKeys.value = [];
|
||||
triggers.value = [];
|
||||
newTableName.value = "";
|
||||
}
|
||||
|
||||
async function loadStructure(silent = false) {
|
||||
if (!props.connectionId || !props.database || !props.tableName) return;
|
||||
if (!silent) loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.connectionId);
|
||||
const nextColumns = await api.getColumns(
|
||||
props.connectionId,
|
||||
props.database,
|
||||
targetSchema.value,
|
||||
props.tableName,
|
||||
);
|
||||
const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([
|
||||
api
|
||||
.listIndexes(props.connectionId, props.database, targetSchema.value, props.tableName)
|
||||
.catch(() => []),
|
||||
api
|
||||
.listForeignKeys(props.connectionId, props.database, targetSchema.value, props.tableName)
|
||||
.catch(() => []),
|
||||
api
|
||||
.listTriggers(props.connectionId, props.database, targetSchema.value, props.tableName)
|
||||
.catch(() => []),
|
||||
]);
|
||||
columns.value = createColumnDrafts(nextColumns);
|
||||
indexes.value = createIndexDrafts(nextIndexes);
|
||||
foreignKeys.value = nextForeignKeys;
|
||||
triggers.value = nextTriggers;
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (!silent) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (!structureCapabilities.value.addColumn) return;
|
||||
columns.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
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 canMoveColumn(index: number, direction: -1 | 1): boolean {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= columns.value.length) return false;
|
||||
if (columns.value[index]?.markedForDrop || columns.value[targetIndex]?.markedForDrop) return false;
|
||||
return isCreateMode.value || structureCapabilities.value.reorderColumn;
|
||||
}
|
||||
|
||||
function moveColumn(index: number, direction: -1 | 1) {
|
||||
if (!canMoveColumn(index, direction)) return;
|
||||
const targetIndex = index + direction;
|
||||
const [column] = columns.value.splice(index, 1);
|
||||
if (!column) return;
|
||||
columns.value.splice(targetIndex, 0, column);
|
||||
}
|
||||
|
||||
function toggleDropColumn(column: EditableStructureColumn) {
|
||||
if (!canDropColumn(column)) return;
|
||||
column.markedForDrop = !column.markedForDrop;
|
||||
}
|
||||
|
||||
function isColumnNameDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.renameColumn);
|
||||
}
|
||||
|
||||
function isColumnTypeDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.alterType);
|
||||
}
|
||||
|
||||
function isColumnNullableDisabled(column: EditableStructureColumn): boolean {
|
||||
return (
|
||||
column.markedForDrop || column.isPrimaryKey || (!!column.original && !structureCapabilities.value.alterNullability)
|
||||
);
|
||||
}
|
||||
|
||||
function isColumnDefaultDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.alterDefault);
|
||||
}
|
||||
|
||||
function isColumnCommentDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || !structureCapabilities.value.comment;
|
||||
}
|
||||
|
||||
function isPrimaryKeyDisabled(column: EditableStructureColumn): boolean {
|
||||
if (column.markedForDrop) return true;
|
||||
if (!column.original) return false;
|
||||
return !structureCapabilities.value.alterPrimaryKey;
|
||||
}
|
||||
|
||||
function canDropColumn(column: EditableStructureColumn): boolean {
|
||||
return !!column.original && !column.isPrimaryKey && structureCapabilities.value.dropColumn;
|
||||
}
|
||||
|
||||
function addIndex() {
|
||||
if (!structureCapabilities.value.createIndex) return;
|
||||
indexes.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
columns: [],
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
filter: "",
|
||||
indexType: "",
|
||||
includedColumns: [],
|
||||
comment: "",
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
|
||||
const availableColumnNames = computed(() =>
|
||||
columns.value
|
||||
.filter((c) => !c.markedForDrop)
|
||||
.map((c) => c.name)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const colSearch = ref("");
|
||||
const filteredColumnNames = computed(() => {
|
||||
const q = colSearch.value.toLowerCase().trim();
|
||||
if (!q) return availableColumnNames.value;
|
||||
return availableColumnNames.value.filter((c) => c.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function toggleIndexColumn(index: EditableStructureIndex, col: string) {
|
||||
const i = index.columns.indexOf(col);
|
||||
if (i >= 0) index.columns.splice(i, 1);
|
||||
else index.columns.push(col);
|
||||
}
|
||||
|
||||
function toggleIncludedColumn(index: EditableStructureIndex, col: string) {
|
||||
if (!structureCapabilities.value.indexInclude) return;
|
||||
const i = index.includedColumns.indexOf(col);
|
||||
if (i >= 0) index.includedColumns.splice(i, 1);
|
||||
else index.includedColumns.push(col);
|
||||
}
|
||||
|
||||
function removeNewIndex(index: EditableStructureIndex) {
|
||||
indexes.value = indexes.value.filter((item) => item.id !== index.id);
|
||||
}
|
||||
|
||||
function toggleDropIndex(index: EditableStructureIndex) {
|
||||
if (!canDropIndex(index)) return;
|
||||
index.markedForDrop = !index.markedForDrop;
|
||||
}
|
||||
|
||||
function canEditIndexDraft(index: EditableStructureIndex): boolean {
|
||||
if (index.markedForDrop || index.isPrimary) return false;
|
||||
if (!index.original) return structureCapabilities.value.createIndex;
|
||||
return (
|
||||
structureCapabilities.value.rebuildIndex &&
|
||||
structureCapabilities.value.createIndex &&
|
||||
structureCapabilities.value.dropIndex
|
||||
);
|
||||
}
|
||||
|
||||
function canEditIndexFilter(index: EditableStructureIndex): boolean {
|
||||
return canEditIndexDraft(index) && structureCapabilities.value.indexFilter;
|
||||
}
|
||||
|
||||
function canEditIndexComment(index: EditableStructureIndex): boolean {
|
||||
return canEditIndexDraft(index) && structureCapabilities.value.indexComment;
|
||||
}
|
||||
|
||||
function canDropIndex(index: EditableStructureIndex): boolean {
|
||||
return !!index.original && !index.isPrimary && structureCapabilities.value.dropIndex;
|
||||
}
|
||||
|
||||
async function applyChanges() {
|
||||
if (!canApply.value || !props.connectionId || !props.database) return;
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.executeBatch(props.connectionId, props.database, pendingStatements.value);
|
||||
toast(t("structureEditor.saved"), 2500);
|
||||
emit("saved");
|
||||
if (isCreateMode.value) {
|
||||
emit("close");
|
||||
} else {
|
||||
await loadStructure(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetState();
|
||||
void loadStructure();
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
isCreateMode,
|
||||
databaseType,
|
||||
() => props.schema,
|
||||
() => props.tableName,
|
||||
newTableName,
|
||||
columns,
|
||||
indexes,
|
||||
],
|
||||
() => {
|
||||
void refreshSqlPreview();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col space-y-2 p-3 text-[11px]" data-structure-density="compact">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-1.5 text-[11px]">
|
||||
<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
|
||||
v-if="!isCreateMode"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="loading || saving"
|
||||
@click="loadStructure()"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCreateMode" class="flex items-center gap-2">
|
||||
<label class="shrink-0 text-[11px] font-medium text-muted-foreground">{{
|
||||
t("structureEditor.tableName")
|
||||
}}</label>
|
||||
<Input
|
||||
v-model="newTableName"
|
||||
:placeholder="t('contextMenu.duplicateNamePlaceholder')"
|
||||
class="h-6 max-w-[220px] text-[11px]"
|
||||
/>
|
||||
</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 flex-1 min-h-0 grid-cols-[minmax(0,1fr)_300px] gap-2">
|
||||
<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-2 py-1.5">
|
||||
<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-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="!structureCapabilities.addColumn"
|
||||
@click="addColumn"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeTab === 'indexes'"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="!structureCapabilities.createIndex"
|
||||
@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-[11px]">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th class="w-7 border-b border-r px-1.5 py-1.5 text-left">#</th>
|
||||
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.columnName") }}
|
||||
</th>
|
||||
<th class="min-w-36 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.dataType") }}
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.nullable") }}
|
||||
</th>
|
||||
<th class="w-14 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-center">
|
||||
{{ t("structureEditor.primaryKey") }}
|
||||
</th>
|
||||
<th class="min-w-28 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.defaultValue") }}
|
||||
</th>
|
||||
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.comment") }}
|
||||
</th>
|
||||
<th class="w-32 border-b px-1.5 py-1.5 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-1.5 py-1 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-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.name"
|
||||
class="h-6 min-w-28 text-[11px]"
|
||||
:disabled="isColumnNameDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.dataType"
|
||||
class="h-6 min-w-32 font-mono text-[11px]"
|
||||
:disabled="isColumnTypeDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="column.isNullable"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="isColumnNullableDisabled(column)"
|
||||
/>
|
||||
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1 text-center">
|
||||
<input
|
||||
v-model="column.isPrimaryKey"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="isPrimaryKeyDisabled(column)"
|
||||
@change="() => { if (column.isPrimaryKey) column.isNullable = false; }"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.defaultValue"
|
||||
class="h-6 min-w-24 font-mono text-[11px]"
|
||||
:disabled="isColumnDefaultDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<div class="flex min-w-36 items-center gap-1">
|
||||
<Input
|
||||
v-model="column.comment"
|
||||
class="h-6 min-w-0 flex-1 text-[11px]"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
:aria-label="t('structureEditor.editComment')"
|
||||
:title="t('structureEditor.editComment')"
|
||||
>
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-[420px] p-2.5">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="min-w-0 truncate text-xs font-medium">
|
||||
{{ t("structureEditor.editComment") }}
|
||||
</span>
|
||||
<span class="max-w-44 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{{ column.name || t("structureEditor.columnName") }}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="column.comment"
|
||||
class="min-h-36 w-full resize-y rounded-md border bg-background px-2.5 py-2 text-xs leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:placeholder="t('structureEditor.commentPlaceholder')"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-b px-1.5 py-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="!canMoveColumn(index, -1)"
|
||||
:title="t('structureEditor.moveColumnUp')"
|
||||
:aria-label="t('structureEditor.moveColumnUp')"
|
||||
@click="moveColumn(index, -1)"
|
||||
>
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="!canMoveColumn(index, 1)"
|
||||
:title="t('structureEditor.moveColumnDown')"
|
||||
:aria-label="t('structureEditor.moveColumnDown')"
|
||||
@click="moveColumn(index, 1)"
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="column.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[11px]"
|
||||
:disabled="!canDropColumn(column)"
|
||||
@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-6 gap-1 px-1.5 text-[11px]"
|
||||
@click="removeNewColumn(column)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
</div>
|
||||
</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-[11px]">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(label, i) in indexColLabels"
|
||||
:key="i"
|
||||
class="relative border-b border-r px-1.5 py-1.5 text-left"
|
||||
:style="{
|
||||
width: indexColWidths[i] + 'px',
|
||||
minWidth: indexColWidths[i] + 'px',
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
<div
|
||||
v-if="i < indexColLabels.length - 1"
|
||||
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
|
||||
:class="resizing?.col === i ? 'bg-primary/30' : ''"
|
||||
@mousedown="onIndexColResize($event, i)"
|
||||
/>
|
||||
</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-1.5 py-1">
|
||||
<Input v-model="index.name" class="h-6 text-[11px]" :disabled="!canEditIndexDraft(index)" />
|
||||
</td>
|
||||
<td class="overflow-hidden border-b border-r px-1.5 py-1">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-6 w-full justify-between px-2 font-mono text-[11px]">
|
||||
<span class="truncate">{{
|
||||
toColumnNames(index.columns) || t("structureEditor.indexColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-[11px]"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.columns.includes(col)"
|
||||
:class="index.columns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIndexColumn(index, col)"
|
||||
>
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="font-mono text-[11px] text-muted-foreground">{{
|
||||
toColumnNames(index.columns)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="index.isUnique"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
/>
|
||||
<span>{{ index.isUnique ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Select
|
||||
v-if="indexTypeOptions.length > 0"
|
||||
:model-value="index.indexType || 'BTREE'"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
@update:model-value="(v: any) => (index.indexType = String(v ?? ''))"
|
||||
>
|
||||
<SelectTrigger class="h-6 font-mono text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in indexTypeOptions" :key="opt" :value="opt">{{ opt }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-else
|
||||
v-model="index.indexType"
|
||||
class="h-6 font-mono text-[11px]"
|
||||
placeholder="BTREE"
|
||||
:disabled="!canEditIndexDraft(index) || !structureCapabilities.indexType"
|
||||
/>
|
||||
</td>
|
||||
<td class="overflow-hidden border-b border-r px-1.5 py-1">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index) && structureCapabilities.indexInclude">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-6 w-full justify-between px-2 font-mono text-[11px]">
|
||||
<span class="truncate">{{
|
||||
index.includedColumns.join(", ") || t("structureEditor.includedColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-[11px]"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.includedColumns.includes(col)"
|
||||
:class="index.includedColumns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIncludedColumn(index, col)"
|
||||
>
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="text-[11px] text-muted-foreground">{{
|
||||
index.includedColumns.join(", ")
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="index.filter"
|
||||
class="h-6 font-mono text-[11px]"
|
||||
:placeholder="index.original?.filter || ''"
|
||||
:disabled="!canEditIndexFilter(index)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="index.comment"
|
||||
class="h-6 text-[11px]"
|
||||
:disabled="!canEditIndexComment(index)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b px-1.5 py-1">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
|
||||
<Button
|
||||
v-else-if="index.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[11px]"
|
||||
:disabled="!canDropIndex(index)"
|
||||
@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-6 gap-1 px-1.5 text-[11px]"
|
||||
@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-2">
|
||||
<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-1.5">
|
||||
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-2 py-1.5 text-[11px]">
|
||||
<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-2">
|
||||
<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-1.5">
|
||||
<div
|
||||
v-for="trigger in triggers"
|
||||
:key="trigger.name"
|
||||
class="rounded-md border px-2 py-1.5 text-[11px]"
|
||||
>
|
||||
<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-2 py-1.5 text-[11px] font-medium">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
||||
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-4 px-1 text-[10px]">
|
||||
<Check class="h-3 w-3" />
|
||||
{{ t("structureEditor.ready") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<Loader2 v-if="sqlPreviewLoading" class="h-3 w-3 animate-spin" />
|
||||
<span v-else>{{ pendingStatements.length }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div v-if="warnings.length" class="mb-2 space-y-1">
|
||||
<div
|
||||
v-for="warning in warnings"
|
||||
:key="warning"
|
||||
class="flex gap-1.5 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-2 py-1 text-[11px] 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-2 font-mono text-[11px] leading-4"
|
||||
>{{ 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-2.5 py-1.5 text-[11px] text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,951 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/utils";
|
||||
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,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Database,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
TableProperties,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
|
||||
import {
|
||||
buildStructureTargetLabel,
|
||||
createColumnDrafts,
|
||||
createIndexDrafts,
|
||||
toColumnNames,
|
||||
} from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
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 sqlPreviewLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const columns = ref<EditableStructureColumn[]>([]);
|
||||
const indexes = ref<EditableStructureIndex[]>([]);
|
||||
const pendingStatements = ref<string[]>([]);
|
||||
const warnings = ref<string[]>([]);
|
||||
const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
||||
const triggers = ref<TriggerInfo[]>([]);
|
||||
|
||||
const indexColWidths = ref([132, 200, 64, 96, 132, 160, 132, 76]);
|
||||
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
|
||||
|
||||
function onIndexColResize(e: MouseEvent, col: number) {
|
||||
e.preventDefault();
|
||||
resizing.value = { col, startX: e.clientX, startW: indexColWidths.value[col] };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!resizing.value) return;
|
||||
const delta = ev.clientX - resizing.value.startX;
|
||||
indexColWidths.value[col] = Math.max(60, resizing.value.startW + delta);
|
||||
};
|
||||
const onUp = () => {
|
||||
resizing.value = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
const connection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined));
|
||||
const databaseType = computed(() => connection.value?.db_type);
|
||||
const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value));
|
||||
|
||||
const indexTypesByDb: Record<string, string[]> = {
|
||||
postgres: ["BTREE", "HASH", "GIST", "SPGIST", "GIN", "BRIN"],
|
||||
mysql: ["BTREE", "HASH", "FULLTEXT", "SPATIAL", "RTREE"],
|
||||
sqlserver: ["CLUSTERED", "NONCLUSTERED", "COLUMNSTORE", "NONCLUSTERED COLUMNSTORE", "XML", "SPATIAL"],
|
||||
oracle: ["NORMAL", "BITMAP", "FUNCTION-BASED NORMAL", "FUNCTION-BASED DOMAIN", "DOMAIN", "CLUSTER"],
|
||||
sqlite: ["BTREE"],
|
||||
};
|
||||
const indexTypeOptions = computed(() =>
|
||||
structureCapabilities.value.indexType ? (indexTypesByDb[databaseType.value ?? ""] ?? []) : [],
|
||||
);
|
||||
|
||||
const indexColLabels = computed(() => [
|
||||
t("structureEditor.indexName"),
|
||||
t("structureEditor.indexColumns"),
|
||||
t("structureEditor.unique"),
|
||||
t("structureEditor.indexType"),
|
||||
t("structureEditor.includedColumns"),
|
||||
t("structureEditor.filter"),
|
||||
t("structureEditor.comment"),
|
||||
t("structureEditor.actions"),
|
||||
]);
|
||||
const targetSchema = computed(() => props.prefillSchema || props.prefillDatabase || "");
|
||||
const isCreateMode = computed(() => !props.prefillTable);
|
||||
const newTableName = ref("");
|
||||
const targetLabel = computed(() =>
|
||||
buildStructureTargetLabel(
|
||||
connection.value?.name,
|
||||
props.prefillDatabase,
|
||||
props.prefillSchema,
|
||||
isCreateMode.value ? undefined : props.prefillTable,
|
||||
),
|
||||
);
|
||||
|
||||
let sqlPreviewRequestId = 0;
|
||||
|
||||
async function refreshSqlPreview() {
|
||||
const requestId = ++sqlPreviewRequestId;
|
||||
if (!open.value) {
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
return;
|
||||
}
|
||||
sqlPreviewLoading.value = true;
|
||||
const options = {
|
||||
databaseType: databaseType.value,
|
||||
schema: props.prefillSchema,
|
||||
tableName: isCreateMode.value ? newTableName.value : props.prefillTable || "",
|
||||
columns: columns.value,
|
||||
indexes: indexes.value,
|
||||
};
|
||||
try {
|
||||
const result = isCreateMode.value
|
||||
? await api.buildCreateTableSql(options)
|
||||
: await api.buildTableStructureChangeSql(options);
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = result.statements;
|
||||
warnings.value = result.warnings;
|
||||
} catch (e: any) {
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [e?.message || String(e)];
|
||||
} finally {
|
||||
if (requestId === sqlPreviewRequestId) sqlPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canApply = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!saving.value &&
|
||||
!sqlPreviewLoading.value &&
|
||||
pendingStatements.value.length > 0 &&
|
||||
warnings.value.length === 0 &&
|
||||
!!props.prefillConnectionId &&
|
||||
(isCreateMode.value ? !!newTableName.value.trim() : !!props.prefillTable),
|
||||
);
|
||||
|
||||
function resetState() {
|
||||
activeTab.value = "columns";
|
||||
loading.value = false;
|
||||
saving.value = false;
|
||||
sqlPreviewLoading.value = false;
|
||||
errorMessage.value = "";
|
||||
columns.value = [];
|
||||
indexes.value = [];
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
foreignKeys.value = [];
|
||||
triggers.value = [];
|
||||
newTableName.value = "";
|
||||
}
|
||||
|
||||
async function loadStructure(silent = false) {
|
||||
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return;
|
||||
if (!silent) loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
const nextColumns = await api.getColumns(
|
||||
props.prefillConnectionId,
|
||||
props.prefillDatabase,
|
||||
targetSchema.value,
|
||||
props.prefillTable,
|
||||
);
|
||||
const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([
|
||||
api
|
||||
.listIndexes(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable)
|
||||
.catch(() => []),
|
||||
api
|
||||
.listForeignKeys(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable)
|
||||
.catch(() => []),
|
||||
api
|
||||
.listTriggers(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, props.prefillTable)
|
||||
.catch(() => []),
|
||||
]);
|
||||
columns.value = createColumnDrafts(nextColumns);
|
||||
indexes.value = createIndexDrafts(nextIndexes);
|
||||
foreignKeys.value = nextForeignKeys;
|
||||
triggers.value = nextTriggers;
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (!silent) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
if (!structureCapabilities.value.addColumn) return;
|
||||
columns.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
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 canMoveColumn(index: number, direction: -1 | 1): boolean {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= columns.value.length) return false;
|
||||
if (columns.value[index]?.markedForDrop || columns.value[targetIndex]?.markedForDrop) return false;
|
||||
return isCreateMode.value || structureCapabilities.value.reorderColumn;
|
||||
}
|
||||
|
||||
function moveColumn(index: number, direction: -1 | 1) {
|
||||
if (!canMoveColumn(index, direction)) return;
|
||||
const targetIndex = index + direction;
|
||||
const [column] = columns.value.splice(index, 1);
|
||||
if (!column) return;
|
||||
columns.value.splice(targetIndex, 0, column);
|
||||
}
|
||||
|
||||
function toggleDropColumn(column: EditableStructureColumn) {
|
||||
if (!canDropColumn(column)) return;
|
||||
column.markedForDrop = !column.markedForDrop;
|
||||
}
|
||||
|
||||
function isColumnNameDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.renameColumn);
|
||||
}
|
||||
|
||||
function isColumnTypeDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.alterType);
|
||||
}
|
||||
|
||||
function isColumnNullableDisabled(column: EditableStructureColumn): boolean {
|
||||
return (
|
||||
column.markedForDrop || column.isPrimaryKey || (!!column.original && !structureCapabilities.value.alterNullability)
|
||||
);
|
||||
}
|
||||
|
||||
function isColumnDefaultDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || (!!column.original && !structureCapabilities.value.alterDefault);
|
||||
}
|
||||
|
||||
function isColumnCommentDisabled(column: EditableStructureColumn): boolean {
|
||||
return column.markedForDrop || !structureCapabilities.value.comment;
|
||||
}
|
||||
|
||||
function isPrimaryKeyDisabled(column: EditableStructureColumn): boolean {
|
||||
if (column.markedForDrop) return true;
|
||||
if (!column.original) return false;
|
||||
return !structureCapabilities.value.alterPrimaryKey;
|
||||
}
|
||||
|
||||
function canDropColumn(column: EditableStructureColumn): boolean {
|
||||
return !!column.original && !column.isPrimaryKey && structureCapabilities.value.dropColumn;
|
||||
}
|
||||
|
||||
function addIndex() {
|
||||
if (!structureCapabilities.value.createIndex) return;
|
||||
indexes.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
columns: [],
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
filter: "",
|
||||
indexType: "",
|
||||
includedColumns: [],
|
||||
comment: "",
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
|
||||
const availableColumnNames = computed(() =>
|
||||
columns.value
|
||||
.filter((c) => !c.markedForDrop)
|
||||
.map((c) => c.name)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const colSearch = ref("");
|
||||
const filteredColumnNames = computed(() => {
|
||||
const q = colSearch.value.toLowerCase().trim();
|
||||
if (!q) return availableColumnNames.value;
|
||||
return availableColumnNames.value.filter((c) => c.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function toggleIndexColumn(index: EditableStructureIndex, col: string) {
|
||||
const i = index.columns.indexOf(col);
|
||||
if (i >= 0) index.columns.splice(i, 1);
|
||||
else index.columns.push(col);
|
||||
}
|
||||
|
||||
function toggleIncludedColumn(index: EditableStructureIndex, col: string) {
|
||||
if (!structureCapabilities.value.indexInclude) return;
|
||||
const i = index.includedColumns.indexOf(col);
|
||||
if (i >= 0) index.includedColumns.splice(i, 1);
|
||||
else index.includedColumns.push(col);
|
||||
}
|
||||
|
||||
function removeNewIndex(index: EditableStructureIndex) {
|
||||
indexes.value = indexes.value.filter((item) => item.id !== index.id);
|
||||
}
|
||||
|
||||
function toggleDropIndex(index: EditableStructureIndex) {
|
||||
if (!canDropIndex(index)) return;
|
||||
index.markedForDrop = !index.markedForDrop;
|
||||
}
|
||||
|
||||
function canEditIndexDraft(index: EditableStructureIndex): boolean {
|
||||
if (index.markedForDrop || index.isPrimary) return false;
|
||||
if (!index.original) return structureCapabilities.value.createIndex;
|
||||
return (
|
||||
structureCapabilities.value.rebuildIndex &&
|
||||
structureCapabilities.value.createIndex &&
|
||||
structureCapabilities.value.dropIndex
|
||||
);
|
||||
}
|
||||
|
||||
function canEditIndexFilter(index: EditableStructureIndex): boolean {
|
||||
return canEditIndexDraft(index) && structureCapabilities.value.indexFilter;
|
||||
}
|
||||
|
||||
function canEditIndexComment(index: EditableStructureIndex): boolean {
|
||||
return canEditIndexDraft(index) && structureCapabilities.value.indexComment;
|
||||
}
|
||||
|
||||
function canDropIndex(index: EditableStructureIndex): boolean {
|
||||
return !!index.original && !index.isPrimary && structureCapabilities.value.dropIndex;
|
||||
}
|
||||
|
||||
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");
|
||||
if (isCreateMode.value) {
|
||||
open.value = false;
|
||||
} else {
|
||||
await loadStructure(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.message || String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(value) => {
|
||||
if (value) {
|
||||
resetState();
|
||||
void loadStructure();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
open,
|
||||
isCreateMode,
|
||||
databaseType,
|
||||
() => props.prefillSchema,
|
||||
() => props.prefillTable,
|
||||
newTableName,
|
||||
columns,
|
||||
indexes,
|
||||
],
|
||||
() => {
|
||||
void refreshSqlPreview();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogScrollContent
|
||||
class="max-h-[calc(100vh-32px)] gap-3 p-3 sm:max-w-[1180px]"
|
||||
:trap-focus="false"
|
||||
@interact-outside.prevent
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<TableProperties class="h-4 w-4" />
|
||||
{{ isCreateMode ? t("structureEditor.createTitle") : t("structureEditor.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-2 py-1 text-[11px]" data-structure-density="compact">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-1.5 text-[11px]">
|
||||
<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
|
||||
v-if="!isCreateMode"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="loading || saving"
|
||||
@click="loadStructure"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCreateMode" class="flex items-center gap-2">
|
||||
<label class="shrink-0 text-[11px] font-medium text-muted-foreground">{{
|
||||
t("structureEditor.tableName")
|
||||
}}</label>
|
||||
<Input
|
||||
v-model="newTableName"
|
||||
:placeholder="t('contextMenu.duplicateNamePlaceholder')"
|
||||
class="h-6 max-w-[220px] text-[11px]"
|
||||
/>
|
||||
</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)_300px] gap-2">
|
||||
<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-2 py-1.5">
|
||||
<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-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="!structureCapabilities.addColumn"
|
||||
@click="addColumn"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeTab === 'indexes'"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-2 text-[11px]"
|
||||
:disabled="!structureCapabilities.createIndex"
|
||||
@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-[11px]">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th class="w-7 border-b border-r px-1.5 py-1.5 text-left">#</th>
|
||||
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.columnName") }}
|
||||
</th>
|
||||
<th class="min-w-36 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.dataType") }}
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.nullable") }}
|
||||
</th>
|
||||
<th class="w-14 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-center">
|
||||
{{ t("structureEditor.primaryKey") }}
|
||||
</th>
|
||||
<th class="min-w-28 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.defaultValue") }}
|
||||
</th>
|
||||
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
|
||||
{{ t("structureEditor.comment") }}
|
||||
</th>
|
||||
<th class="w-32 border-b px-1.5 py-1.5 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-1.5 py-1 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-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.name"
|
||||
class="h-6 min-w-28 text-[11px]"
|
||||
:disabled="isColumnNameDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.dataType"
|
||||
class="h-6 min-w-32 font-mono text-[11px]"
|
||||
:disabled="isColumnTypeDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="column.isNullable"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="isColumnNullableDisabled(column)"
|
||||
/>
|
||||
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1 text-center">
|
||||
<input
|
||||
v-model="column.isPrimaryKey"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="isPrimaryKeyDisabled(column)"
|
||||
@change="() => { if (column.isPrimaryKey) column.isNullable = false; }"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="column.defaultValue"
|
||||
class="h-6 min-w-24 font-mono text-[11px]"
|
||||
:disabled="isColumnDefaultDisabled(column)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<div class="flex min-w-36 items-center gap-1">
|
||||
<Input
|
||||
v-model="column.comment"
|
||||
class="h-6 min-w-0 flex-1 text-[11px]"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
:aria-label="t('structureEditor.editComment')"
|
||||
:title="t('structureEditor.editComment')"
|
||||
>
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-[420px] p-2.5">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="min-w-0 truncate text-xs font-medium">
|
||||
{{ t("structureEditor.editComment") }}
|
||||
</span>
|
||||
<span class="max-w-44 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{{ column.name || t("structureEditor.columnName") }}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="column.comment"
|
||||
class="min-h-36 w-full resize-y rounded-md border bg-background px-2.5 py-2 text-xs leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:placeholder="t('structureEditor.commentPlaceholder')"
|
||||
:disabled="isColumnCommentDisabled(column)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-b px-1.5 py-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="!canMoveColumn(index, -1)"
|
||||
:title="t('structureEditor.moveColumnUp')"
|
||||
:aria-label="t('structureEditor.moveColumnUp')"
|
||||
@click="moveColumn(index, -1)"
|
||||
>
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="!canMoveColumn(index, 1)"
|
||||
:title="t('structureEditor.moveColumnDown')"
|
||||
:aria-label="t('structureEditor.moveColumnDown')"
|
||||
@click="moveColumn(index, 1)"
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="column.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[11px]"
|
||||
:disabled="!canDropColumn(column)"
|
||||
@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-6 gap-1 px-1.5 text-[11px]"
|
||||
@click="removeNewColumn(column)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
{{ t("structureEditor.remove") }}
|
||||
</Button>
|
||||
</div>
|
||||
</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-[11px]">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(label, i) in indexColLabels"
|
||||
:key="i"
|
||||
class="relative border-b border-r px-1.5 py-1.5 text-left"
|
||||
:style="{
|
||||
width: indexColWidths[i] + 'px',
|
||||
minWidth: indexColWidths[i] + 'px',
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
<div
|
||||
v-if="i < indexColLabels.length - 1"
|
||||
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
|
||||
:class="resizing?.col === i ? 'bg-primary/30' : ''"
|
||||
@mousedown="onIndexColResize($event, i)"
|
||||
/>
|
||||
</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-1.5 py-1">
|
||||
<Input v-model="index.name" class="h-6 text-[11px]" :disabled="!canEditIndexDraft(index)" />
|
||||
</td>
|
||||
<td class="overflow-hidden border-b border-r px-1.5 py-1">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-6 w-full justify-between px-2 font-mono text-[11px]">
|
||||
<span class="truncate">{{
|
||||
toColumnNames(index.columns) || t("structureEditor.indexColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-[11px]"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.columns.includes(col)"
|
||||
:class="index.columns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIndexColumn(index, col)"
|
||||
>
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="font-mono text-[11px] text-muted-foreground">{{
|
||||
toColumnNames(index.columns)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="index.isUnique"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
/>
|
||||
<span>{{ index.isUnique ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Select
|
||||
v-if="indexTypeOptions.length > 0"
|
||||
:model-value="index.indexType || 'BTREE'"
|
||||
:disabled="!canEditIndexDraft(index)"
|
||||
@update:model-value="(v: any) => (index.indexType = String(v ?? ''))"
|
||||
>
|
||||
<SelectTrigger class="h-6 font-mono text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in indexTypeOptions" :key="opt" :value="opt">{{ opt }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-else
|
||||
v-model="index.indexType"
|
||||
class="h-6 font-mono text-[11px]"
|
||||
placeholder="BTREE"
|
||||
:disabled="!canEditIndexDraft(index) || !structureCapabilities.indexType"
|
||||
/>
|
||||
</td>
|
||||
<td class="overflow-hidden border-b border-r px-1.5 py-1">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index) && structureCapabilities.indexInclude">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" class="h-6 w-full justify-between px-2 font-mono text-[11px]">
|
||||
<span class="truncate">{{
|
||||
index.includedColumns.join(", ") || t("structureEditor.includedColumnsPlaceholder")
|
||||
}}</span>
|
||||
<ChevronDown class="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="max-h-56 min-w-44 overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="2"
|
||||
:avoid-collisions="false"
|
||||
@interactOutside="colSearch = ''"
|
||||
>
|
||||
<div class="px-1.5 pb-1 pt-0.5">
|
||||
<Input
|
||||
v-model="colSearch"
|
||||
class="h-6 text-[11px]"
|
||||
:placeholder="t('grid.search')"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="col in filteredColumnNames"
|
||||
:key="col"
|
||||
:checked="index.includedColumns.includes(col)"
|
||||
:class="index.includedColumns.includes(col) ? 'bg-primary/10' : ''"
|
||||
@select.prevent
|
||||
@click="toggleIncludedColumn(index, col)"
|
||||
>
|
||||
{{ col }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span v-else class="text-[11px] text-muted-foreground">{{
|
||||
index.includedColumns.join(", ")
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="index.filter"
|
||||
class="h-6 font-mono text-[11px]"
|
||||
:placeholder="index.original?.filter || ''"
|
||||
:disabled="!canEditIndexFilter(index)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-r px-1.5 py-1">
|
||||
<Input
|
||||
v-model="index.comment"
|
||||
class="h-6 text-[11px]"
|
||||
:disabled="!canEditIndexComment(index)"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b px-1.5 py-1">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
|
||||
<Button
|
||||
v-else-if="index.original"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[11px]"
|
||||
:disabled="!canDropIndex(index)"
|
||||
@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-6 gap-1 px-1.5 text-[11px]"
|
||||
@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-2">
|
||||
<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-1.5">
|
||||
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-2 py-1.5 text-[11px]">
|
||||
<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-2">
|
||||
<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-1.5">
|
||||
<div
|
||||
v-for="trigger in triggers"
|
||||
:key="trigger.name"
|
||||
class="rounded-md border px-2 py-1.5 text-[11px]"
|
||||
>
|
||||
<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-2 py-1.5 text-[11px] font-medium">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
||||
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-4 px-1 text-[10px]">
|
||||
<Check class="h-3 w-3" />
|
||||
{{ t("structureEditor.ready") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<Loader2 v-if="sqlPreviewLoading" class="h-3 w-3 animate-spin" />
|
||||
<span v-else>{{ pendingStatements.length }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div v-if="warnings.length" class="mb-2 space-y-1">
|
||||
<div
|
||||
v-for="warning in warnings"
|
||||
:key="warning"
|
||||
class="flex gap-1.5 rounded-md border border-yellow-300/40 bg-yellow-500/10 px-2 py-1 text-[11px] 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-2 font-mono text-[11px] leading-4"
|
||||
>{{ 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-2.5 py-1.5 text-[11px] 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>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -10,7 +10,6 @@ const showDataCompareDialog = ref(false);
|
|||
const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showStructureEditorDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
const showDatabaseExportDialog = ref(false);
|
||||
|
|
@ -40,10 +39,6 @@ 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 lineagePrefillConnectionId = ref("");
|
||||
const lineagePrefillDatabase = ref("");
|
||||
const lineagePrefillSchema = ref("");
|
||||
|
|
@ -147,20 +142,6 @@ export function useDialogSources() {
|
|||
},
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.fieldLineageSource,
|
||||
(v) => {
|
||||
|
|
@ -274,7 +255,6 @@ export function useDialogSources() {
|
|||
showSqlFileDialog,
|
||||
showDiagramDialog,
|
||||
showTableImportDialog,
|
||||
showStructureEditorDialog,
|
||||
showFieldLineageDialog,
|
||||
showDatabaseSearchDialog,
|
||||
showDatabaseExportDialog,
|
||||
|
|
@ -303,10 +283,6 @@ export function useDialogSources() {
|
|||
tableImportPrefillDatabase,
|
||||
tableImportPrefillSchema,
|
||||
tableImportPrefillTable,
|
||||
structurePrefillConnectionId,
|
||||
structurePrefillDatabase,
|
||||
structurePrefillSchema,
|
||||
structurePrefillTable,
|
||||
lineagePrefillConnectionId,
|
||||
lineagePrefillDatabase,
|
||||
lineagePrefillSchema,
|
||||
|
|
|
|||
|
|
@ -105,10 +105,6 @@ async function openTableTarget(target: NavigationTarget) {
|
|||
export function useNavigationTargets(dialogs: {
|
||||
showFieldLineageDialog: { value: boolean };
|
||||
showDatabaseSearchDialog: { value: boolean };
|
||||
structurePrefillTable: { value: string };
|
||||
structurePrefillConnectionId?: { value: string };
|
||||
structurePrefillDatabase?: { value: string };
|
||||
structurePrefillSchema?: { value: string };
|
||||
}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
|
|
@ -126,20 +122,16 @@ export function useNavigationTargets(dialogs: {
|
|||
async function onStructureEditorSaved(
|
||||
reloadData: () => Promise<void>,
|
||||
toast: (msg: string, duration?: number) => void,
|
||||
context: { connectionId: string; database: string; schema?: string; tableName: string },
|
||||
) {
|
||||
if (!dialogs.structurePrefillTable.value) {
|
||||
const connId = dialogs.structurePrefillConnectionId?.value;
|
||||
const db = dialogs.structurePrefillDatabase?.value;
|
||||
const schema = dialogs.structurePrefillSchema?.value;
|
||||
if (connId && db) {
|
||||
try {
|
||||
await connectionStore.refreshObjectListTreeNode(connId, db, schema || undefined);
|
||||
} catch {}
|
||||
}
|
||||
if (!context.tableName) {
|
||||
try {
|
||||
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
const activeTab = queryStore.tabs.find((t) => t.id === queryStore.activeTabId);
|
||||
if (activeTab?.mode === "data" && activeTab.tableMeta?.tableName === dialogs.structurePrefillTable.value) {
|
||||
if (activeTab?.mode === "data" && activeTab.tableMeta?.tableName === context.tableName) {
|
||||
try {
|
||||
const columns = await api.getColumns(
|
||||
activeTab.connectionId,
|
||||
|
|
|
|||
|
|
@ -877,6 +877,7 @@ export default {
|
|||
structureEditor: {
|
||||
title: "Edit Table Structure",
|
||||
createTitle: "Create Table",
|
||||
editTabTitle: "Edit: {tableName}",
|
||||
tableName: "Table Name",
|
||||
refresh: "Refresh Structure",
|
||||
columns: "Columns",
|
||||
|
|
|
|||
|
|
@ -774,6 +774,7 @@ export default {
|
|||
structureEditor: {
|
||||
title: "Editar estructura de tabla",
|
||||
createTitle: "Crear tabla",
|
||||
editTabTitle: "Editar: {tableName}",
|
||||
tableName: "Nombre de tabla",
|
||||
refresh: "Actualizar estructura",
|
||||
columns: "Columnas",
|
||||
|
|
|
|||
|
|
@ -858,6 +858,7 @@ export default {
|
|||
structureEditor: {
|
||||
title: "编辑表结构",
|
||||
createTitle: "新建表",
|
||||
editTabTitle: "编辑: {tableName}",
|
||||
tableName: "表名",
|
||||
refresh: "刷新结构",
|
||||
columns: "字段",
|
||||
|
|
|
|||
|
|
@ -101,12 +101,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
schema?: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
const structureEditorSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
const fieldLineageSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
|
|
@ -1782,7 +1776,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
sqlFileSource,
|
||||
diagramSource,
|
||||
tableImportSource,
|
||||
structureEditorSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
databaseExportSource,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { DatabaseType, QueryTab } from "@/types/database";
|
||||
import { orderPinnedFirst } from "@/lib/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
|
|
@ -43,6 +44,7 @@ function loadSavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } {
|
|||
}
|
||||
|
||||
export const useQueryStore = defineStore("query", () => {
|
||||
const { t } = useI18n();
|
||||
const restored = loadSavedTabs();
|
||||
const tabs = ref<QueryTab[]>(restored.tabs);
|
||||
const activeTabId = ref<string | null>(restored.activeTabId);
|
||||
|
|
@ -193,6 +195,40 @@ export const useQueryStore = defineStore("query", () => {
|
|||
return id;
|
||||
}
|
||||
|
||||
function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string) {
|
||||
const resolvedTableName = tableName || "";
|
||||
const existing = tabs.value.find(
|
||||
(tab) =>
|
||||
tab.mode === "structure" &&
|
||||
tab.connectionId === connectionId &&
|
||||
tab.database === database &&
|
||||
(tab.structureTableName || "") === resolvedTableName,
|
||||
);
|
||||
if (existing) {
|
||||
activeTabId.value = existing.id;
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const title = resolvedTableName ? t("structureEditor.editTabTitle", { tableName: resolvedTableName }) : t("structureEditor.createTitle");
|
||||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "structure",
|
||||
structureTableName: resolvedTableName,
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
const idx = tabs.value.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
|
|
@ -799,6 +835,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
closeAllTabs,
|
||||
updateSql,
|
||||
openObjectBrowser,
|
||||
openTableStructure,
|
||||
linkSavedSql,
|
||||
openSavedSql,
|
||||
togglePinnedTab,
|
||||
|
|
|
|||
|
|
@ -322,7 +322,8 @@ export interface QueryTab {
|
|||
executionId?: string;
|
||||
isExplaining?: boolean;
|
||||
explainExecutionId?: string;
|
||||
mode: "data" | "query" | "redis" | "mongo" | "objects";
|
||||
mode: "data" | "query" | "redis" | "mongo" | "objects" | "structure";
|
||||
structureTableName?: string;
|
||||
objectBrowser?: {
|
||||
schema?: string;
|
||||
objectType?: "tables";
|
||||
|
|
|
|||
Loading…
Reference in New Issue