From 92ef1ac3cf754db117bbb73d6cf617365572888f Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 6 Jul 2026 14:16:43 +0800 Subject: [PATCH] feat(structure): open editor from field and index nodes --- .../src/components/layout/ContentArea.vue | 1 + .../src/components/sidebar/TreeItem.vue | 34 +++- .../structure/TableStructureEditor.vue | 171 ++++++++++++++++-- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + apps/desktop/src/stores/queryStore.ts | 16 +- apps/desktop/src/types/database.ts | 6 + packages/app-tests/queryStore.test.ts | 13 +- 13 files changed, 258 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 4e82e0ceac..12a0ddc39a 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1412,6 +1412,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe :table-name="activeTab.structureTableName || ''" :initial-tab="activeTab.structureInitialTab" :initial-tab-request-id="activeTab.structureInitialTabRequestId" + :initial-target="activeTab.structureInitialTarget" :draft="activeTab.structureDraft" @update:draft="(draft) => (activeTab.structureDraft = draft)" @saved="(commentChanged) => emit('structureEditorSaved', commentChanged)" diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 1a3d1fbc5d..ecda4a8444 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -3790,8 +3790,22 @@ function openTableImport() { function openStructureEditor() { const node = props.node; - if (node.type !== "table" || !node.connectionId || !node.database) return; - queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.label); + if (!node.connectionId || !node.database) return; + if (node.type === "table") { + queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.label); + return; + } + if (node.type === "column" && node.tableName) { + const columnName = tableChildDropObjectName(node).trim(); + if (!columnName) return; + queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.tableName, "columns", { kind: "column", name: columnName }); + return; + } + if (node.type === "index" && node.tableName) { + const indexName = tableChildDropObjectName(node).trim(); + if (!indexName) return; + queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.tableName, "indexes", { kind: "index", name: indexName }); + } } function openFieldLineage() { @@ -3835,7 +3849,8 @@ const canOpenTableImport = computed(() => { return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableImport(currentDatabaseType()); }); const canOpenStructureEditor = computed(() => { - return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType()); + const editableNode = props.node.type === "table" || ((props.node.type === "column" || props.node.type === "index") && !!props.node.tableName); + return editableNode && !isSqlServerLinkedNode(props.node) && !!props.node.connectionId && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType()); }); const canOpenFieldLineage = computed(() => { return props.node.type === "column" && !!props.node.database && !!props.node.tableName && supportsFieldLineage(currentDatabaseType()); @@ -4756,9 +4771,16 @@ function treeItemMenuItems(): ContextMenuItem[] { // 7. Column if (node.type === "column") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + const columnActions: ContextMenuItem[] = []; + if (canOpenStructureEditor.value) { + columnActions.push({ label: t("contextMenu.editColumn"), action: openStructureEditor, icon: PencilRuler }); + } if (canOpenFieldLineage.value) { + columnActions.push({ label: t("lineage.open"), action: openFieldLineage, icon: Network }); + } + if (columnActions.length > 0) { items.push({ label: "", separator: true }); - items.push({ label: t("lineage.open"), action: openFieldLineage, icon: Network }); + items.push(...columnActions); } if (canDropTableChildObject.value) { items.push({ label: "", separator: true }); @@ -4775,6 +4797,10 @@ function treeItemMenuItems(): ContextMenuItem[] { if (node.type === "index" || node.type === "fkey" || node.type === "trigger") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + if (node.type === "index" && canOpenStructureEditor.value) { + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.editIndex"), action: openStructureEditor, icon: PencilRuler }); + } if (node.type === "index" && canDropMongoIndex.value) { items.push({ label: "", separator: true }); items.push({ diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 743d7c8484..79735641b5 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -28,7 +28,7 @@ import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/li import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import type { TableInfoTab, TableStructureEditorDraft } from "@/types/database"; +import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget } from "@/types/database"; import { buildStructureTargetLabel, combineDataTypeForDatabase, @@ -85,6 +85,7 @@ const props = defineProps<{ tableName: string; initialTab?: TableInfoTab; initialTabRequestId?: number; + initialTarget?: TableStructureEditorTarget; draft?: TableStructureEditorDraft; }>(); @@ -494,8 +495,12 @@ const resizing = ref<{ col: number; startX: number; startW: number } | null>(nul const columnSearchInputRef = ref>(); const columnSearchText = ref(""); const highlightedColumnId = ref(null); +const indexSearchInputRef = ref>(); +const indexSearchText = ref(""); +const highlightedIndexId = ref(null); const sqlPreviewCollapsed = ref(loadSqlPreviewCollapsed()); let columnHighlightTimer: ReturnType | undefined; +let indexHighlightTimer: ReturnType | undefined; watch( structureDensity, @@ -689,6 +694,12 @@ const filteredColumnRowIds = computed(() => { ); }); const columnSearchMatchCount = computed(() => (columnSearchText.value.trim() ? filteredColumnRowIds.value.size : 0)); +const filteredIndexRowIds = computed(() => { + const query = indexSearchText.value.trim().toLowerCase(); + if (!query) return new Set(); + return new Set(indexes.value.filter((index) => indexMatchesSearch(index, query)).map((index) => index.id)); +}); +const indexSearchMatchCount = computed(() => (indexSearchText.value.trim() ? filteredIndexRowIds.value.size : 0)); const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"]; const triggerTimingOptions = ["BEFORE", "AFTER"]; const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"]; @@ -723,6 +734,10 @@ let restoringDraft = false; let syncingDraft = false; let draftHydrated = false; let hydratingRestoredDraft = false; +// A context-menu target may arrive before metadata rows render, so search text +// and row scrolling are tracked separately for each request. +let appliedInitialTargetSearchKey = ""; +let appliedInitialTargetScrollKey = ""; function cloneDraftValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -977,6 +992,12 @@ function resetState() { newTableName.value = ""; tableComment.value = ""; originalTableComment.value = ""; + columnSearchText.value = ""; + highlightedColumnId.value = null; + indexSearchText.value = ""; + highlightedIndexId.value = null; + appliedInitialTargetSearchKey = ""; + appliedInitialTargetScrollKey = ""; } async function reloadStructureFromDatabase() { @@ -1441,6 +1462,76 @@ function onColumnSearchKeydown(event: KeyboardEvent) { scrollToColumnSearchMatch(event.shiftKey ? -1 : 1); } +function indexMatchesSearch(index: EditableStructureIndex, searchQuery = indexSearchText.value.trim().toLowerCase()): boolean { + if (!searchQuery) return false; + return [index.name, toColumnNames(index.columns), index.includedColumns.join(", "), index.indexType, index.filter, index.comment].some((value) => + String(value ?? "") + .toLowerCase() + .includes(searchQuery), + ); +} + +function indexFieldMatchesSearch(value: string | null | undefined): boolean { + const query = indexSearchText.value.trim().toLowerCase(); + return ( + !!query && + String(value ?? "") + .toLowerCase() + .includes(query) + ); +} + +function indexRowClass(index: EditableStructureIndex) { + const isSearchMatch = filteredIndexRowIds.value.has(index.id); + return { + "bg-destructive/5 opacity-60": index.markedForDrop, + "structure-column-search-match": isSearchMatch, + "structure-column-search-current": highlightedIndexId.value === index.id, + }; +} + +function indexSearchFieldClass(index: EditableStructureIndex, value: string | null | undefined) { + const matches = indexFieldMatchesSearch(value); + return { + "!border-primary/60 !bg-primary/10": matches, + "!border-primary !ring-2 !ring-primary/30": matches && highlightedIndexId.value === index.id, + }; +} + +function focusIndexSearch() { + activeTab.value = "indexes"; + void nextTick(() => { + const input = indexSearchInputRef.value?.$el as HTMLInputElement | undefined; + input?.focus(); + input?.select(); + }); +} + +function scrollToIndexSearchMatch(direction: 1 | -1 = 1) { + const query = indexSearchText.value.trim(); + if (!query) { + focusIndexSearch(); + return; + } + const rows = Array.from(rootRef.value?.querySelectorAll("[data-index-row-index]") ?? []); + const matches = indexes.value.map((index, rowIndex) => ({ index, rowIndex })).filter(({ index }) => indexMatchesSearch(index)); + if (!matches.length) return; + const currentIndex = highlightedIndexId.value ? matches.findIndex(({ index }) => index.id === highlightedIndexId.value) : -1; + const nextMatch = matches[(currentIndex + direction + matches.length) % matches.length] ?? matches[0]; + highlightedIndexId.value = nextMatch.index.id; + rows[nextMatch.rowIndex]?.scrollIntoView({ block: "center", inline: "nearest" }); + if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer); + indexHighlightTimer = window.setTimeout(() => { + highlightedIndexId.value = null; + }, 1800); +} + +function onIndexSearchKeydown(event: KeyboardEvent) { + if (event.key !== "Enter") return; + event.preventDefault(); + scrollToIndexSearchMatch(event.shiftKey ? -1 : 1); +} + function columnDragInsertionIndex(index: number, event: DragEvent): number { const target = event.currentTarget; if (!(target instanceof HTMLElement)) return index; @@ -1816,16 +1907,18 @@ function unregisterStructureEditorShortcuts() { onMounted(() => { resetState(); applyInitialStructureTab(); + applyInitialStructureTarget(); registerStructureEditorShortcuts(); void loadDynamicDataTypeOptions(); if (props.draft?.initialized) { restoreDraft(props.draft); applyInitialStructureTab(); - void hydrateRestoredDraftFromDatabase(); + applyInitialStructureTarget(); + void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget()); } else if (isCreateMode.value) { markDraftHydratedAndSync(); } else { - void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true }); + void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget()); } }); @@ -1834,7 +1927,8 @@ onActivated(() => { void loadDynamicDataTypeOptions(); if (props.draft?.initialized && !draftHydrated) { restoreDraft(props.draft); - void hydrateRestoredDraftFromDatabase(); + applyInitialStructureTarget(); + void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget()); } }); onDeactivated(unregisterStructureEditorShortcuts); @@ -1843,6 +1937,7 @@ onBeforeUnmount(() => { unregisterStructureEditorShortcuts(); clearSqlPreviewState(); if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer); + if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer); persistStructureDensity(); }); @@ -1869,13 +1964,53 @@ function applyInitialStructureTab() { activeTab.value = resolveStructureMetadataTab(props.initialTab); } +function initialTargetKey(target: TableStructureEditorTarget): string { + return `${props.initialTabRequestId ?? 0}:${target.kind}:${target.name}`; +} + +function applyInitialStructureTarget() { + const target = props.initialTarget; + const targetName = target?.name.trim(); + if (!target || !targetName) return; + + const key = initialTargetKey(target); + if (appliedInitialTargetSearchKey !== key) { + if (target.kind === "column") { + activeTab.value = resolveStructureMetadataTab("columns"); + columnSearchText.value = targetName; + highlightedColumnId.value = null; + } else { + activeTab.value = resolveStructureMetadataTab("indexes"); + indexSearchText.value = targetName; + highlightedIndexId.value = null; + } + appliedInitialTargetSearchKey = key; + } + + if (appliedInitialTargetScrollKey === key) return; + const hasMatch = target.kind === "column" ? columns.value.some((column) => columnMatchesSearch(column)) : indexes.value.some((index) => indexMatchesSearch(index)); + if (!hasMatch) return; + appliedInitialTargetScrollKey = key; + void nextTick(() => { + if (target.kind === "column") { + scrollToColumnSearchMatch(1); + } else { + scrollToIndexSearchMatch(1); + } + }); +} + watch(tableMetadataCapabilities, (capabilities) => { if (!isStructureMetadataTabSupported(activeTab.value, capabilities)) activeTab.value = firstStructureMetadataTab(capabilities); }); -watch([() => props.initialTab, () => props.initialTabRequestId], () => { - if (!props.initialTab) return; - applyInitialStructureTab(); +watch([() => props.initialTab, () => props.initialTabRequestId, () => props.initialTarget], () => { + if (props.initialTab) applyInitialStructureTab(); + applyInitialStructureTarget(); +}); + +watch([columns, indexes], () => { + applyInitialStructureTarget(); }); watch([() => props.connectionId, () => props.database, databaseType], () => { @@ -1893,6 +2028,7 @@ watch( watch(activeTab, () => { highlightedColumnId.value = null; + highlightedIndexId.value = null; syncDraftToParent(); }); @@ -2036,6 +2172,19 @@ watch(activeTab, (tab) => { {{ t("structureEditor.configureColumnTemplates") }} +
+ + + +