diff --git a/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue b/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue index 5fcce8fbd8..97c310a501 100644 --- a/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue +++ b/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue @@ -23,8 +23,10 @@ function confirmCreate() { if (!scope || !name) return; const groupId = connectionStore.createTableVGroup(scope, name, tableVGroupDialogParentGroupId.value); if (groupId) { + // scope 即右键的源行节点,其 type 就是这批待移入行的类别(多选保证同类型)。 + const rowType = scope.type; for (const tableName of tableVGroupDialogTableNames.value) { - connectionStore.moveTableToVGroup(scope, tableName, groupId); + connectionStore.moveTableToVGroup(scope, tableName, groupId, rowType); } emit("created", tableVGroupNodeId(groupId)); } diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 41d61a25bf..29f241c5a5 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -85,7 +85,7 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu"; import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter"; import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout"; -import { hasTableVGroupEntries, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupsEnabled } from "@/lib/table/tableVGroup"; +import { hasTableVGroupEntries, isTableVGroupContainerNode, isTableVGroupGroupableRowType, resolveTableVGroupScopeFromNode, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupScopeKey, tableVGroupsEnabled } from "@/lib/table/tableVGroup"; import { objectTypesForGroupNode } from "@/lib/table/tableTree"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus"; @@ -5958,6 +5958,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (currentDatabaseType() === "hbase" && node.type === "table") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + // HBase 表行走本特化分支(先于 buildObjectSidebarMenu 的统一注入短路),分组移动入口需在此补齐。 + if (isTableVGroupGroupableRowType(node.type) && !!tableVGroupScopeKey(resolveTableVGroupScopeFromNode(connectionStore.treeNodes, node))) { + const vgroupMoveItems = buildTableVGroupMoveMenuItems(node); + if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems }); + } items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties }); items.push({ @@ -6150,6 +6155,15 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { const { node, items, deleteMenuLabel, deleteMenuAction, truncateMenuLabel, truncateMenuAction, emptyMenuLabel, emptyMenuAction, batchAutoIncrementCount, autoIncrementMenuLabel, autoIncrementMenuAction } = context; + // 虚拟分组移动入口:对所有可分组行类型统一注入(含 procedure/function/trigger/ + // sequence/event/synonym/job/package/type 等专属分支——它们各自 return true, + // 若只在 table/view/mv 分支注入,这些行将没有任何分组入口)。表行保持原行为 + // (simple 模式也能经「移动到新分组」建组);其余行须已处于同类别分组容器内, + // 否则解析不出 scope,菜单只会静默失败。 + if (isTableVGroupGroupableRowType(node.type) && (node.type === "table" || !!tableVGroupScopeKey(resolveTableVGroupScopeFromNode(connectionStore.treeNodes, node)))) { + const vgroupMoveItems = buildTableVGroupMoveMenuItems(node); + if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems }); + } // 6. Table / View / Materialized View if (node.type === "table" || node.type === "view" || node.type === "materialized_view") { if (currentDatabaseType() === "victoriametrics" && node.type === "table") { @@ -6169,10 +6183,6 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { } const destructiveActions: ContextMenuItem[] = []; items.push(copyNameMenuItem()); - if (node.type === "table") { - const vgroupMoveItems = buildTableVGroupMoveMenuItems(node); - if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems }); - } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); if (node.type === "table" && supportsAiAssistantContext(currentDatabaseType())) { items.push(addToAiMenuItem(node)); @@ -6590,20 +6600,21 @@ function buildTableVGroupMoveMenuItems(node: TreeNode): ContextMenuItem[] { const layout = connectionStore.tableVGroupLayoutFor(node); const targets = selectedTableVGroupMoveTargets(node, selectedTreeNodesInVisibleOrder()); const targetNames = targets.map((target) => target.label); - const targetsInGroup = (groupId: string) => targetNames.every((name) => tableVGroupPathForTable(layout, name).includes(groupId)); + const targetRowType = targets[0]?.type; + const targetsInGroup = (groupId: string) => targetNames.every((name) => tableVGroupPathForTable(layout, name, targetRowType).includes(groupId)); const items: ContextMenuItem[] = tableVGroupDestinationRows(layout).map((row) => ({ label: row.name, title: row.path.join(" / "), disabled: targetNames.length > 0 && targetsInGroup(row.id), action: () => { - for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, row.id); + for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, row.id, targetRowType); }, })); - if (targetNames.some((name) => tableVGroupPathForTable(layout, name).length > 0)) { + if (targetNames.some((name) => tableVGroupPathForTable(layout, name, targetRowType).length > 0)) { items.push({ label: t("tableVGroup.removeFromGroup"), action: () => { - for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, null); + for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, null, targetRowType); }, }); } @@ -6623,7 +6634,7 @@ function buildTableVGroupMoveMenuItems(node: TreeNode): ContextMenuItem[] { } function appendTableVGroupContainerItems(node: TreeNode, items: ContextMenuItem[]) { - if (node.type !== "database" && node.type !== "schema" && node.type !== "linked-server-schema" && node.type !== "group-tables") return; + if (!isTableVGroupContainerNode(node)) return; const layout = connectionStore.tableVGroupLayoutFor(node); if (!hasTableVGroupEntries(layout)) return; const enabled = tableVGroupsEnabled(layout); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 3ad03c8ee0..dd829c2e6e 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -74,7 +74,7 @@ import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage import { effectiveRedisDatabaseIndex } from "@/lib/redis/redisDatabaseIndex"; import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { selectedTableVGroupMoveTargets, tableVGroupIdFromNodeId } from "@/lib/table/tableVGroup"; +import { isTableVGroupGroupableRowType, selectedTableVGroupMoveTargets, tableVGroupIdFromNodeId } from "@/lib/table/tableVGroup"; import { findTreeNodeById } from "@/lib/sql/newQueryContext"; import { resolveTableVGroupDropTarget, setTableVGroupDropTargetNodeId, tableVGroupDropTargetNodeId } from "@/lib/sidebar/sidebarTableVGroupDrag"; import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation"; @@ -1375,9 +1375,10 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null { function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) { draggingTableReferencePayload = payload; - // 分组成员名单只收真实表:视图/物化视图投影不识别,入组会产生隐形脏数据。 + // 分组成员名单按可分组行类型收集(表/视图/物化视图/过程/函数/触发器/序列等); + // 无匹配容器的行由 scope 解析兜底拒绝,不会产生隐形脏数据。 vgroupDragTableNames = selectedTableVGroupMoveTargets(activeNode.value, selectedTreeNodesInVisibleOrder()) - .filter((node) => node.type === "table") + .filter((node) => isTableVGroupGroupableRowType(node.type)) .map((node) => node.label); setActiveTableReferencePayload(payload); document.getSelection()?.removeAllRanges(); @@ -1402,7 +1403,8 @@ let vgroupDragTableNames: string[] = []; function tableVGroupDropTargetFor(payload: QueryEditorTableReferencePayload, event: MouseEvent) { if (!vgroupDragTableNames.length) return null; - return resolveTableVGroupDropTarget(event.clientX, event.clientY, connectionStore.treeNodes, payload); + // 拖拽源是树行(多选同类型),落点解析按行类别过滤跨类别容器。 + return resolveTableVGroupDropTarget(event.clientX, event.clientY, connectionStore.treeNodes, { ...payload, objectType: activeNode.value.type }); } function onTableReferenceMouseMove(event: MouseEvent) { @@ -1431,7 +1433,7 @@ function onTableReferenceMouseUp(event: MouseEvent) { suppressNextTableReferenceClick = true; const dropTarget = tableVGroupDropTargetFor(payload, event); if (dropTarget) { - for (const tableName of vgroupDragTableNames) connectionStore.moveTableToVGroup(dropTarget.node, tableName, dropTarget.groupId); + for (const tableName of vgroupDragTableNames) connectionStore.moveTableToVGroup(dropTarget.node, tableName, dropTarget.groupId, activeNode.value.type); } else { const target = document.elementFromPoint(event.clientX, event.clientY); if (target instanceof Element && target.closest(`[data-query-editor-root], ${AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR}`)) { diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 9f4b4ad195..328b28c7fd 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -24,7 +24,7 @@ export const deleteConnectionsWithGroup = ref(false); export const showTableVGroupDialog = ref(false); export const tableVGroupName = ref(""); /** Scope snapshot + creation payload for the table vgroup naming dialog. */ -export const tableVGroupDialogScope = shallowRef(null); +export const tableVGroupDialogScope = shallowRef(null); export const tableVGroupDialogParentGroupId = ref(null); export const tableVGroupDialogTableNames = ref([]); export const showTableVGroupDeleteConfirm = ref(false); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 2ca45541fa..8b92166361 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2674,17 +2674,17 @@ export default withEnglishFallback({ refresh: "Yenidən təhlil et", }, tableVGroup: { - moveToGroup: "Cədvəl qrupuna köçür", + moveToGroup: "Qrupa köçür", moveToNewGroup: "Yeni qrup yarat və köçür", removeFromGroup: "Qrupdan çıxar", newGroupDefault: "Yeni qrup", newSubgroup: "Yeni alt qrup", deleteGroup: "Qrupu sil", - disableGroups: "Cədvəl qruplarını gizlət", - enableGroups: "Cədvəl qruplarını göstər", - deleteGroupConfirmTitle: "Cədvəl qrupunu sil", - deleteGroupConfirmMessage: '"{name}" qrupu silinsin? Qrupdakı cədvəllər siyahıya qayıdır, verilənlər bazası dəyişmir.', - groupDeleted: "Cədvəl qrupu silindi", + disableGroups: "Qrupları gizlət", + enableGroups: "Qrupları göstər", + deleteGroupConfirmTitle: "Qrupu sil", + deleteGroupConfirmMessage: '"{name}" qrupu silinsin? Qrupdakı üzvlər siyahıya qayıdır, verilənlər bazası dəyişmir.', + groupDeleted: "Qrup silindi", }, connectionGroup: { createGroup: "Yeni qrup", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 282c1ab36b..0899f4d480 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2870,17 +2870,17 @@ export default { refresh: "Analyze Again", }, tableVGroup: { - moveToGroup: "Move to Table Group", + moveToGroup: "Move to Group", moveToNewGroup: "New Group and Move In", removeFromGroup: "Remove from Group", newGroupDefault: "New Group", newSubgroup: "New Subgroup", deleteGroup: "Delete Group", - disableGroups: "Hide Table Groups", - enableGroups: "Show Table Groups", - deleteGroupConfirmTitle: "Delete Table Group", - deleteGroupConfirmMessage: 'Delete group "{name}"? Its tables return to the table list; the database is untouched.', - groupDeleted: "Table group deleted", + disableGroups: "Hide Groups", + enableGroups: "Show Groups", + deleteGroupConfirmTitle: "Delete Group", + deleteGroupConfirmMessage: 'Delete group "{name}"? Its members return to the object list; the database is untouched.', + groupDeleted: "Group deleted", }, connectionGroup: { createGroup: "New Group", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ae7ae37744..92de378fd7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2770,17 +2770,17 @@ export default withEnglishFallback({ refresh: "Analizar de nuevo", }, tableVGroup: { - moveToGroup: "Mover a grupo de tablas", + moveToGroup: "Mover a grupo", moveToNewGroup: "Nuevo grupo y mover", removeFromGroup: "Quitar del grupo", newGroupDefault: "Nuevo grupo", newSubgroup: "Nuevo subgrupo", deleteGroup: "Eliminar grupo", - disableGroups: "Ocultar grupos de tablas", - enableGroups: "Mostrar grupos de tablas", - deleteGroupConfirmTitle: "Eliminar grupo de tablas", - deleteGroupConfirmMessage: "¿Eliminar el grupo «{name}»? Sus tablas vuelven a la lista y la base de datos no se modifica.", - groupDeleted: "Grupo de tablas eliminado", + disableGroups: "Ocultar grupos", + enableGroups: "Mostrar grupos", + deleteGroupConfirmTitle: "Eliminar grupo", + deleteGroupConfirmMessage: "¿Eliminar el grupo «{name}»? Sus miembros vuelven a la lista y la base de datos no se modifica.", + groupDeleted: "Grupo eliminado", }, connectionGroup: { createGroup: "Nuevo grupo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 45f6712c6e..304e71df6f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2767,17 +2767,17 @@ export default withEnglishFallback({ refresh: "Analizza di Nuovo", }, tableVGroup: { - moveToGroup: "Sposta nel gruppo tabelle", + moveToGroup: "Sposta nel gruppo", moveToNewGroup: "Nuovo gruppo e sposta", removeFromGroup: "Rimuovi dal gruppo", newGroupDefault: "Nuovo gruppo", newSubgroup: "Nuovo sottogruppo", deleteGroup: "Elimina gruppo", - disableGroups: "Nascondi gruppi tabelle", - enableGroups: "Mostra gruppi tabelle", - deleteGroupConfirmTitle: "Elimina gruppo tabelle", - deleteGroupConfirmMessage: "Eliminare il gruppo «{name}»? Le tabelle tornano nell'elenco e il database non viene modificato.", - groupDeleted: "Gruppo tabelle eliminato", + disableGroups: "Nascondi gruppi", + enableGroups: "Mostra gruppi", + deleteGroupConfirmTitle: "Elimina gruppo", + deleteGroupConfirmMessage: "Eliminare il gruppo «{name}»? I membri tornano nell'elenco e il database non viene modificato.", + groupDeleted: "Gruppo eliminato", }, connectionGroup: { createGroup: "Nuovo Gruppo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2e41b6cb11..d8038faf75 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2793,17 +2793,17 @@ export default withEnglishFallback({ refresh: "再分析", }, tableVGroup: { - moveToGroup: "テーブルグループへ移動", + moveToGroup: "グループへ移動", moveToNewGroup: "新規グループを作成して移動", removeFromGroup: "グループから外す", newGroupDefault: "新規グループ", newSubgroup: "新規サブグループ", deleteGroup: "グループを削除", - disableGroups: "テーブルグループを非表示", - enableGroups: "テーブルグループを表示", - deleteGroupConfirmTitle: "テーブルグループの削除", - deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内のテーブルは一覧に戻り、データベースは変更されません。", - groupDeleted: "テーブルグループを削除しました", + disableGroups: "グループを非表示", + enableGroups: "グループを表示", + deleteGroupConfirmTitle: "グループの削除", + deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内のメンバーは一覧に戻り、データベースは変更されません。", + groupDeleted: "グループを削除しました", }, connectionGroup: { createGroup: "新しいグループ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 026fafc699..8dfeb2587b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2676,17 +2676,17 @@ export default withEnglishFallback({ refresh: "다시 분석", }, tableVGroup: { - moveToGroup: "테이블 그룹으로 이동", + moveToGroup: "그룹으로 이동", moveToNewGroup: "새 그룹 만들고 이동", removeFromGroup: "그룹에서 제거", newGroupDefault: "새 그룹", newSubgroup: "새 하위 그룹", deleteGroup: "그룹 삭제", - disableGroups: "테이블 그룹 숨기기", - enableGroups: "테이블 그룹 표시", - deleteGroupConfirmTitle: "테이블 그룹 삭제", - deleteGroupConfirmMessage: '"{name}" 그룹을 삭제할까요? 그룹의 테이블은 목록으로 돌아가며 데이터베이스는 변경되지 않습니다.', - groupDeleted: "테이블 그룹을 삭제했습니다", + disableGroups: "그룹 숨기기", + enableGroups: "그룹 표시", + deleteGroupConfirmTitle: "그룹 삭제", + deleteGroupConfirmMessage: '"{name}" 그룹을 삭제할까요? 그룹의 멤버는 목록으로 돌아가며 데이터베이스는 변경되지 않습니다.', + groupDeleted: "그룹을 삭제했습니다", }, connectionGroup: { createGroup: "새 그룹", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1fae3ab7da..d122694436 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2769,17 +2769,17 @@ export default withEnglishFallback({ refresh: "Analisar Novamente", }, tableVGroup: { - moveToGroup: "Mover para grupo de tabelas", + moveToGroup: "Mover para grupo", moveToNewGroup: "Novo grupo e mover", removeFromGroup: "Remover do grupo", newGroupDefault: "Novo grupo", newSubgroup: "Novo subgrupo", deleteGroup: "Excluir grupo", - disableGroups: "Ocultar grupos de tabelas", - enableGroups: "Mostrar grupos de tabelas", - deleteGroupConfirmTitle: "Excluir grupo de tabelas", - deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? As tabelas voltam para a lista e o banco de dados não é alterado.', - groupDeleted: "Grupo de tabelas excluído", + disableGroups: "Ocultar grupos", + enableGroups: "Mostrar grupos", + deleteGroupConfirmTitle: "Excluir grupo", + deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? Os membros voltam para a lista e o banco de dados não é alterado.', + groupDeleted: "Grupo excluído", }, connectionGroup: { createGroup: "Novo Grupo", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index be39205335..f2d9968a59 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2652,17 +2652,17 @@ export default withEnglishFallback({ refresh: "Yeniden Analiz Et", }, tableVGroup: { - moveToGroup: "Tablo grubuna taşı", + moveToGroup: "Gruba taşı", moveToNewGroup: "Yeni grup oluştur ve taşı", removeFromGroup: "Gruptan çıkar", newGroupDefault: "Yeni grup", newSubgroup: "Yeni alt grup", deleteGroup: "Grubu sil", - disableGroups: "Tablo gruplarını gizle", - enableGroups: "Tablo gruplarını göster", - deleteGroupConfirmTitle: "Tablo grubunu sil", - deleteGroupConfirmMessage: '"{name}" grubu silinsin mi? Gruptaki tablolar listeye döner, veritabanı değişmez.', - groupDeleted: "Tablo grubu silindi", + disableGroups: "Grupları gizle", + enableGroups: "Grupları göster", + deleteGroupConfirmTitle: "Grubu sil", + deleteGroupConfirmMessage: '"{name}" grubu silinsin mi? Gruptaki üyeler listeye döner, veritabanı değişmez.', + groupDeleted: "Grup silindi", }, connectionGroup: { createGroup: "Yeni Grup", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eda3fa943c..f2aa516e35 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2782,17 +2782,17 @@ export default withEnglishFallback({ refresh: "重新分析", }, tableVGroup: { - moveToGroup: "移动到表分组", + moveToGroup: "移动到分组", moveToNewGroup: "新建分组并移入", removeFromGroup: "移出分组", newGroupDefault: "新建分组", newSubgroup: "新建子分组", deleteGroup: "删除分组", - disableGroups: "隐藏表分组", - enableGroups: "显示表分组", - deleteGroupConfirmTitle: "删除表分组", - deleteGroupConfirmMessage: "确定删除分组「{name}」?分组内的表会回到表列表,数据库不受影响。", - groupDeleted: "表分组已删除", + disableGroups: "隐藏分组", + enableGroups: "显示分组", + deleteGroupConfirmTitle: "删除分组", + deleteGroupConfirmMessage: "确定删除分组「{name}」?分组内的成员会回到原列表,数据库不受影响。", + groupDeleted: "分组已删除", }, connectionGroup: { createGroup: "新建分组", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e36c27b63f..368773b514 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2767,17 +2767,17 @@ export default withEnglishFallback({ refresh: "重新分析", }, tableVGroup: { - moveToGroup: "移動到表分組", + moveToGroup: "移動到分組", moveToNewGroup: "新建分組並移入", removeFromGroup: "移出分組", newGroupDefault: "新建分組", newSubgroup: "新建子分組", deleteGroup: "刪除分組", - disableGroups: "隱藏表分組", - enableGroups: "顯示表分組", - deleteGroupConfirmTitle: "刪除表分組", - deleteGroupConfirmMessage: "確定刪除分組「{name}」?分組內的資料表會回到清單,資料庫不受影響。", - groupDeleted: "表分組已刪除", + disableGroups: "隱藏分組", + enableGroups: "顯示分組", + deleteGroupConfirmTitle: "刪除分組", + deleteGroupConfirmMessage: "確定刪除分組「{name}」?分組內的成員會回到清單,資料庫不受影響。", + groupDeleted: "分組已刪除", }, connectionGroup: { createGroup: "建立群組", diff --git a/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts b/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts index 3a3f723f57..477eba0db8 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts @@ -1,7 +1,7 @@ import { shallowRef } from "vue"; import type { TreeNode } from "@/types/database"; import { findTreeNodeById } from "@/lib/sql/newQueryContext"; -import { isTableVGroupContainerNode } from "@/lib/table/tableVGroup"; +import { isTableVGroupContainerNode, tableVGroupDropAcceptsRow } from "@/lib/table/tableVGroup"; /** * Shared drop feedback for dragging a table row onto a virtual group row. @@ -26,7 +26,7 @@ export interface TableVGroupDropTarget { * above the row, so a single elementFromPoint hit often misses it. Cross-scope * drops are rejected so a table name never lands in another database's layout. */ -export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: TreeNode[], source: { connectionId?: string; database?: string; schema?: string }): TableVGroupDropTarget | null { +export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: TreeNode[], source: { connectionId?: string; database?: string; schema?: string; objectType?: TreeNode["type"] }): TableVGroupDropTarget | null { // Some environments (and component-test DOMs) do not implement hit testing. const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : []; for (const element of elements) { @@ -37,6 +37,9 @@ export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: Tr if (!node || node.connectionId !== source.connectionId || (node.database ?? "") !== (source.database ?? "")) continue; // 跨 schema:双方都声明且不同 → 拒绝(任一方未知则容忍,交给 database 校验兜底)。 if (node.schema && source.schema && node.schema !== source.schema) continue; + // 跨类别:视图/过程/触发器行不得落入其他类别的分组或容器,否则会写幻影成员、 + // 同名时还会误移真实成员。objectType 缺省(表引用拖拽)保持既有行为。 + if (source.objectType && !tableVGroupDropAcceptsRow(treeNodes, node, source.objectType)) continue; if (node.type === "table-vgroup" && node.vgroupId) return { node, groupId: node.vgroupId }; if (isTableVGroupContainerNode(node)) return { node, groupId: null }; } diff --git a/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts b/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts index c194c3e1a2..866b28a831 100644 --- a/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts +++ b/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts @@ -8,7 +8,10 @@ import { createTableVGroup, deleteTableVGroups, emptyTableVGroupLayout, + findTableVGroupContainerNode, hasTableTreeLoadMore, + isTableVGroupContainerNode, + isTableVGroupGroupableRowType, moveTableToVGroup, normalizeTableVGroupLayout, pruneTableVGroupMembers, @@ -17,6 +20,8 @@ import { selectedTableVGroupMoveTargets, stripTableVGroupsFromChildren, tableVGroupDestinationRows, + tableVGroupDropAcceptsRow, + tableVGroupKindOfContainerNode, tableVGroupPathForTable, tableVGroupScopeKey, toggleTableVGroupCollapsed, @@ -390,3 +395,144 @@ function findGroupChildren(layout: ReturnType, g }; return visit(layout.order); } + +describe("container-kind scoped groups (views/procedures/triggers)", () => { + const VIEW_SCOPE = { connectionId: "conn-1", database: "db1", objectType: "views" as const }; + + function viewNode(name: string): TreeNode { + return { id: `view:${name}`, label: name, type: "view", connectionId: "conn-1", database: "db1" }; + } + + function materializedViewNode(name: string): TreeNode { + return { id: `mview:${name}`, label: name, type: "materialized_view", connectionId: "conn-1", database: "db1" }; + } + + it("keeps the tables scope key byte-identical and suffixes the other kinds", () => { + expect(tableVGroupScopeKey({ connectionId: "conn-1", database: "db1" })).toBe("conn-1\u0000\u0000\u0000db1\u0000"); + expect(tableVGroupScopeKey(VIEW_SCOPE)).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000views"); + expect(tableVGroupScopeKey({ connectionId: "conn-1", database: "db1", objectType: "triggers" })).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000triggers"); + }); + + it("maps containers to kinds and rejects table-child trigger groups", () => { + expect(tableVGroupKindOfContainerNode({ type: "database" })).toBe("tables"); + expect(tableVGroupKindOfContainerNode({ type: "group-tables" })).toBe("tables"); + expect(tableVGroupKindOfContainerNode({ type: "group-views" })).toBe("views"); + expect(tableVGroupKindOfContainerNode({ type: "group-materialized-views" })).toBe("materialized_views"); + expect(tableVGroupKindOfContainerNode({ type: "group-procedures" })).toBe("procedures"); + expect(tableVGroupKindOfContainerNode({ type: "group-functions" })).toBe("functions"); + expect(tableVGroupKindOfContainerNode({ type: "group-triggers" })).toBe("triggers"); + expect(tableVGroupKindOfContainerNode({ type: "group-triggers", tableName: "t1" })).toBeNull(); + expect(tableVGroupKindOfContainerNode({ type: "group-sequences" })).toBe("sequences"); + expect(tableVGroupKindOfContainerNode({ type: "group-events" })).toBe("events"); + expect(tableVGroupKindOfContainerNode({ type: "group-synonyms" })).toBe("synonyms"); + expect(tableVGroupKindOfContainerNode({ type: "group-jobs" })).toBe("jobs"); + expect(tableVGroupKindOfContainerNode({ type: "group-packages" })).toBe("packages"); + expect(tableVGroupKindOfContainerNode({ type: "group-types" })).toBe("types"); + expect(tableVGroupKindOfContainerNode({ type: "group-tablespaces" })).toBeNull(); + expect(tableVGroupKindOfContainerNode({ type: "group-dolt-system-tables" })).toBeNull(); + }); + + it("projects view rows under the views scope and passes non-member rows through", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "支付视图"); + const withMember = moveTableToVGroup(created.layout, "v_payment", created.groupId); + const projected = applyTableVGroupsToChildren([materializedViewNode("mv_a"), viewNode("v_payment"), viewNode("v_other")], withMember, VIEW_SCOPE); + expect(groupIds(projected)).toHaveLength(1); + expect(projected[0]!.label).toBe("支付视图"); + expect((projected[0]!.children ?? []).map((node) => node.label)).toEqual(["v_payment"]); + expect(projected.map((node) => node.type)).toEqual(["table-vgroup", "materialized_view", "view"]); + }); + + it("re-projecting view containers stays idempotent", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "分组"); + const withMember = moveTableToVGroup(created.layout, "v_a", created.groupId); + const once = applyTableVGroupsToChildren([viewNode("v_a"), viewNode("v_b")], withMember, VIEW_SCOPE); + const twice = applyTableVGroupsToChildren(once, withMember, VIEW_SCOPE); + expect(groupIds(twice)).toEqual(groupIds(once)); + }); + + it("resolves view rows and projected group rows to the views scope of the host", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const groupRow: TreeNode = { id: "table-vgroup:g1", label: "分组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g1" }; + const groupViews: TreeNode = { id: "gv:conn-1:db1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [groupRow, viewNode("v_a")] }; + const tree: TreeNode[] = [{ ...database, children: [groupViews, viewNode("v_a")] }]; + expect(resolveTableVGroupScopeFromNode(tree, viewNode("v_a"))).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" }); + expect(resolveTableVGroupScopeFromNode(tree, groupRow)).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" }); + }); + + it("refuses a scope for view rows without a views container (simple mixed lists)", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1", children: [tableNode("t_a"), viewNode("v_a")] }; + expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode([database], viewNode("v_a")))).toBeNull(); + }); + + it("resolves a member row already inside a projected group to the same views scope", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const member: TreeNode = { id: "view:v_in_group", label: "v_in_group", type: "view", connectionId: "conn-1", database: "db1" }; + const groupRow: TreeNode = { id: "table-vgroup:g9", label: "组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g9", children: [member] }; + const groupViews: TreeNode = { id: "gv:2", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [groupRow, viewNode("v_top")] }; + const tree: TreeNode[] = [{ ...database, children: [groupViews] }]; + expect(resolveTableVGroupScopeFromNode(tree, member)).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" }); + }); + + it("narrows reprojection candidates to the scope kind even without a member label", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const groupTables: TreeNode = { id: "gt:1", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "db1" }; + const groupFunctions: TreeNode = { id: "gf:1", label: "Functions", type: "group-functions", connectionId: "conn-1", database: "db1", children: [{ id: "fn:1", label: "vg_count", type: "function", connectionId: "conn-1", database: "db1" }] }; + const tree: TreeNode[] = [{ ...database, children: [groupTables, groupFunctions] }]; + expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1", objectType: "tables" })).toBe(tree[0]); + }); + + it("collects member names per container kind", () => { + const children = [viewNode("v_a"), tableNode("t_a"), materializedViewNode("mv_a")]; + expect(collectTableTreeNames(children, "views")).toEqual(new Set(["v_a"])); + expect(collectTableTreeNames(children, "materialized_views")).toEqual(new Set(["mv_a"])); + expect(collectTableTreeNames(children, "tables")).toEqual(new Set(["t_a"])); + }); + + it("keeps mixed selections type-pure when moving to groups", () => { + const view = viewNode("v_a"); + const table = tableNode("t_a"); + expect(isTableVGroupGroupableRowType("materialized_view")).toBe(true); + expect(isTableVGroupGroupableRowType("load-more")).toBe(false); + expect(isTableVGroupGroupableRowType("index")).toBe(false); + expect(selectedTableVGroupMoveTargets(view, [view, table])).toEqual([view]); + expect(selectedTableVGroupMoveTargets(view, [view, viewNode("v_b")])).toEqual([view, viewNode("v_b")]); + }); + + it("finds the holding container of a member row by kind", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const groupViews: TreeNode = { id: "gv:conn-1:db1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [viewNode("v_a")] }; + const tree: TreeNode[] = [{ ...database, children: [groupViews] }]; + expect(isTableVGroupContainerNode(groupViews)).toBe(true); + expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "v_a", "view")).toBe(groupViews); + // kind 隔离:表成员定位永不落入 views 容器;缺失成员回退宿主(分页语义) + expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "v_a", "table")).toBe(tree[0]); + expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "t_missing", "table")).toBe(tree[0]); + }); +}); + +describe("container-kind drop and scope isolation", () => { + it("rejects cross-kind drop targets and accepts same-kind ones", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const groupViews: TreeNode = { id: "gv:1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [{ id: "vg-row:1", label: "分组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g1" }] }; + const tree: TreeNode[] = [{ ...database, children: [groupViews] }]; + expect(tableVGroupDropAcceptsRow(tree, groupViews, "view")).toBe(true); + expect(tableVGroupDropAcceptsRow(tree, groupViews, "table")).toBe(false); + expect(tableVGroupDropAcceptsRow(tree, database, "view")).toBe(false); + expect(tableVGroupDropAcceptsRow(tree, database, "table")).toBe(true); + const groupRow = groupViews.children![0]!; + expect(tableVGroupDropAcceptsRow(tree, groupRow, "view")).toBe(true); + expect(tableVGroupDropAcceptsRow(tree, groupRow, "table")).toBe(false); + }); + + it("refuses a scope for table-child trigger rows sharing names with database triggers", () => { + const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" }; + const dbTrigger: TreeNode = { id: "dbtrig:1", label: "tg_a", type: "trigger", connectionId: "conn-1", database: "db1" }; + const dbTriggers: TreeNode = { id: "gt:1", label: "Triggers", type: "group-triggers", connectionId: "conn-1", database: "db1", children: [dbTrigger] }; + const tableTrigger: TreeNode = { id: "tbltrig:1", label: "tg_a", type: "trigger", connectionId: "conn-1", database: "db1", parentName: "t1" }; + const tableTriggers: TreeNode = { id: "tt:1", label: "Triggers", type: "group-triggers", connectionId: "conn-1", database: "db1", tableName: "t1", children: [tableTrigger] }; + const table: TreeNode = { id: "table:t1", label: "t1", type: "table", connectionId: "conn-1", database: "db1", children: [tableTriggers] }; + const tree: TreeNode[] = [{ ...database, children: [dbTriggers, table] }]; + expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode(tree, dbTrigger))).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000triggers"); + expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode(tree, tableTrigger))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/table/tableVGroup.ts b/apps/desktop/src/lib/table/tableVGroup.ts index d5044adf45..fde1fec371 100644 --- a/apps/desktop/src/lib/table/tableVGroup.ts +++ b/apps/desktop/src/lib/table/tableVGroup.ts @@ -13,12 +13,21 @@ export type { TableVGroupLayout, TableVGroupOrderEntry }; export type TableVGroupDropPosition = "before" | "after" | "inside"; +/** + * 分组作用域覆盖的对象容器类别。tables 是 #3470 起的既有行为;views / + * procedures / triggers 对应 #9446 方向一与 #9733;其余容器类别为同一机制的 + * 全量补齐(与 groupDefs 的库级对象容器一一对应)。 + */ +export type TableVGroupObjectKind = "tables" | "views" | "materialized_views" | "procedures" | "functions" | "triggers" | "sequences" | "events" | "synonyms" | "jobs" | "packages" | "types"; + export interface TableVGroupScope { connectionId?: string; catalog?: string; database?: string; schema?: string; linkedServer?: string; + /** 本布局归属的容器类别;缺省视为 tables(历史 scope 均为表分组)。 */ + objectType?: TableVGroupObjectKind; } type TableVGroupEntry = Extract; @@ -38,7 +47,10 @@ export function tableVGroupsEnabled(layout: TableVGroupLayout | null | undefined export function tableVGroupScopeKey(scope: TableVGroupScope): string | null { if (!scope.connectionId || !scope.database) return null; - return [scope.connectionId, scope.linkedServer ?? "", scope.catalog ?? "", scope.database, scope.schema ?? ""].join("\u0000"); + const parts = [scope.connectionId, scope.linkedServer ?? "", scope.catalog ?? "", scope.database, scope.schema ?? ""]; + // 仅非表容器在尾部追加类别段:表分组的 key 与历史持久化逐字节一致,零迁移。 + if (scope.objectType && scope.objectType !== "tables") parts.push(scope.objectType); + return parts.join("\u0000"); } const TABLE_VGROUP_NODE_ID_PREFIX = "table-vgroup:"; @@ -53,28 +65,106 @@ export function tableVGroupIdFromNodeId(nodeId: string): string | null { return nodeId.startsWith(TABLE_VGROUP_NODE_ID_PREFIX) ? nodeId.slice(TABLE_VGROUP_NODE_ID_PREFIX.length) : null; } -/** Tree node types whose direct children may contain table nodes to group. */ -const TABLE_VGROUP_CONTAINER_TYPES: ReadonlySet = new Set(["database", "schema", "linked-server-schema", "group-tables"]); +/** + * 承载可分组行的容器行 → 分组类别。group-triggers 双栖:库级触发器列表与 + * 表结构子组共用同一 type,表子级带 tableName,不算库级分组容器。 + */ +export function tableVGroupKindOfContainerNode(node: Pick): TableVGroupObjectKind | null { + switch (node.type) { + case "database": + case "schema": + case "linked-server-schema": + case "group-tables": + return "tables"; + case "group-views": + return "views"; + case "group-materialized-views": + return "materialized_views"; + case "group-procedures": + return "procedures"; + case "group-functions": + return "functions"; + case "group-triggers": + // 双栖 type:库级触发器列表与表结构子组共用;表子级带 tableName。 + return node.tableName ? null : "triggers"; + case "group-sequences": + return "sequences"; + case "group-events": + return "events"; + case "group-synonyms": + return "synonyms"; + case "group-jobs": + return "jobs"; + case "group-packages": + return "packages"; + case "group-types": + return "types"; + default: + return null; + } +} + +/** 各类别容器里可被分组的行 type;成员身份仍是容器内的行 label。 */ +const TABLE_VGROUP_KIND_ROW_TYPES: Record> = { + tables: { table: true }, + views: { view: true }, + materialized_views: { materialized_view: true }, + procedures: { procedure: true }, + functions: { function: true }, + triggers: { trigger: true }, + sequences: { sequence: true }, + events: { event: true }, + synonyms: { synonym: true }, + jobs: { job: true }, + // packages/types 的 spec 与 body 行同名(label 键控成员身份),入组必然合并为 + // 单一成员条目——已知限制:同名双行无法分别归属不同分组。 + packages: { package: true, "package-body": true }, + types: { type: true, "type-body": true }, +}; + +/** 该行 type 是否可被分入某类容器(拖拽源、移动菜单等入口的快速过滤)。 */ +export function isTableVGroupGroupableRowType(type: TreeNode["type"]): boolean { + switch (type) { + case "table": + case "view": + case "materialized_view": + case "procedure": + case "function": + case "trigger": + case "sequence": + case "event": + case "synonym": + case "job": + case "package": + case "package-body": + case "type": + case "type-body": + return true; + default: + return false; + } +} /** 行的身份字段即 scope 字段;分组节点与各类容器直接自带这五项。 */ function tableVGroupScopeFieldsOf(node: TreeNode): TableVGroupScope { return { connectionId: node.connectionId, catalog: node.catalog, database: node.database, schema: node.schema, linkedServer: node.linkedServer }; } -/** 承载表行的容器行:分组投影的挂载点,也是拖拽「移出分组」的落点。 */ +/** 分组投影的挂载点,也是拖拽「移出分组」的落点。 */ export function isTableVGroupContainerNode(node: TreeNode): boolean { - return TABLE_VGROUP_CONTAINER_TYPES.has(node.type); + return tableVGroupKindOfContainerNode(node) !== null; } /** * Selection semantics for "move to group", mirroring the connection tree: when - * several rows are selected and the right-clicked table is part of that + * several rows are selected and the right-clicked row is part of that * selection, the whole selection moves; anything outside the right-clicked - * table's connection+database scope is ignored. Otherwise only the clicked row. + * row's connection+database+type scope is ignored. Otherwise only the clicked + * row. */ export function selectedTableVGroupMoveTargets(currentNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] { - if (currentNode.type !== "table" || !currentNode.connectionId) return currentNode.type === "table" ? [currentNode] : []; - const inScope = (node: TreeNode) => node.type === "table" && node.connectionId === currentNode.connectionId && (node.database ?? "") === (currentNode.database ?? "") && (node.schema ?? "") === (currentNode.schema ?? ""); + if (!isTableVGroupGroupableRowType(currentNode.type) || !currentNode.connectionId) return isTableVGroupGroupableRowType(currentNode.type) ? [currentNode] : []; + const inScope = (node: TreeNode) => node.type === currentNode.type && node.connectionId === currentNode.connectionId && (node.database ?? "") === (currentNode.database ?? "") && (node.schema ?? "") === (currentNode.schema ?? ""); const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id); if (selectedNodes.length > 1 && selectedContainsCurrent && selectedNodes.every(inScope)) return [...selectedNodes]; return [currentNode]; @@ -104,8 +194,13 @@ function containsGroup(entry: TableVGroupOrderEntry, groupId: string): boolean { return entryChildren(entry).some((child) => containsGroup(child, groupId)); } -function removeTableFromEntries(entries: TableVGroupOrderEntry[], tableName: string): TableVGroupOrderEntry[] { - return entries.filter((entry) => !(entry.type === "table" && entry.name === tableName)).map((entry) => (entry.type === "group" ? { type: "group" as const, id: entry.id, children: removeTableFromEntries(entryChildren(entry), tableName) } : entry)); +/** 成员条目与 (名字, 行类型) 的匹配:rowType 给定时旧的无类型条目(历史数据)也视为命中。 */ +function matchesTableEntry(entry: TableVGroupOrderEntry, name: string, rowType?: string): boolean { + return entry.type === "table" && entry.name === name && (!rowType || !entry.rowType || entry.rowType === rowType); +} + +function removeTableFromEntries(entries: TableVGroupOrderEntry[], tableName: string, rowType?: string): TableVGroupOrderEntry[] { + return entries.filter((entry) => !matchesTableEntry(entry, tableName, rowType)).map((entry) => (entry.type === "group" ? { type: "group" as const, id: entry.id, children: removeTableFromEntries(entryChildren(entry), tableName, rowType) } : entry)); } function expandGroup(layout: TableVGroupLayout, groupId: string): TableVGroupLayout { @@ -115,10 +210,10 @@ function expandGroup(layout: TableVGroupLayout, groupId: string): TableVGroupLay }; } -export function moveTableToVGroup(layout: TableVGroupLayout, tableName: string, targetGroupId: string | null): TableVGroupLayout { +export function moveTableToVGroup(layout: TableVGroupLayout, tableName: string, targetGroupId: string | null, rowType?: string): TableVGroupLayout { if (!hasTableVGroupEntries(layout)) return layout; - const order = removeTableFromEntries(cloneEntries(layout.order), tableName); - const entry: TableVGroupOrderEntry = { type: "table", name: tableName }; + const order = removeTableFromEntries(cloneEntries(layout.order), tableName, rowType); + const entry: TableVGroupOrderEntry = { type: "table", name: tableName, ...(rowType ? { rowType } : {}) }; if (targetGroupId) { const group = findGroupEntry(order, targetGroupId); @@ -277,11 +372,11 @@ export function tableVGroupDestinationRows(layout: TableVGroupLayout | null | un } /** Stable root-to-leaf group-id path containing the table, empty when ungrouped. */ -export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undefined, tableName: string): string[] { +export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undefined, tableName: string, rowType?: string): string[] { if (!hasTableVGroupEntries(layout)) return []; const walk = (entries: TableVGroupOrderEntry[], path: string[]): string[] | null => { for (const entry of entries) { - if (entry.type === "table" && entry.name === tableName) return path; + if (matchesTableEntry(entry, tableName, rowType)) return path; if (entry.type !== "group") continue; const found = walk(entryChildren(entry), [...path, entry.id]); if (found) return found; @@ -295,17 +390,30 @@ export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undef // Display projection // --------------------------------------------------------------------------- -interface TableVGroupFlatTable { +interface TableVGroupFlatRow { node: TreeNode; flatIndex: number; } -function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map, scope: TableVGroupScope): TreeNode[] { +/** 双索引成员行查找:rowType 精确键 O(1);无 rowType(历史条目与表)走同名首行索引 O(1)。 */ +interface TableVGroupRowLookup { + byKey: Map; + byLabel: Map; +} + +function findRowIn(lookup: TableVGroupRowLookup, name: string, rowType?: string): TableVGroupFlatRow | undefined { + if (rowType) return lookup.byKey.get(`${rowType}\u0000${name}`); + return lookup.byLabel.get(name); +} + +const tableRowKey = (type: string, label: string): string => `${type}\u0000${label}`; + +function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, lookup: TableVGroupRowLookup, scope: TableVGroupScope): TreeNode[] { const nodes: TreeNode[] = []; for (const entry of entries) { if (entry.type === "table") { - const table = tables.get(entry.name); - if (table) nodes.push(table.node); + const row = findRowIn(lookup, entry.name, entry.rowType); + if (row) nodes.push(row.node); continue; } const group = layout.groups.find((candidate) => candidate.id === entry.id); @@ -320,8 +428,9 @@ function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVG schema: scope.schema, linkedServer: scope.linkedServer, vgroupId: entry.id, + vgroupKind: scope.objectType ?? "tables", isExpanded: !group.collapsed, - children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope), + children: buildVGroupChildNodes(entryChildren(entry), layout, lookup, scope), }); } return nodes; @@ -330,15 +439,15 @@ function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVG /** * Build the root-level projected nodes. Virtual groups always float to the top * of the container in layout order (matching Navicat's table groups), followed - * by ungrouped tables and non-table rows at their original flat positions. + * by ungrouped rows and non-member rows at their original flat positions. */ -function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map, scope: TableVGroupScope): Array<{ node: TreeNode; sortIndex: number }> { +function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, lookup: TableVGroupRowLookup, scope: TableVGroupScope): Array<{ node: TreeNode; sortIndex: number }> { const out: Array<{ node: TreeNode; sortIndex: number }> = []; let groupSeq = 0; for (const entry of entries) { if (entry.type === "table") { - const table = tables.get(entry.name); - if (table) out.push({ node: table.node, sortIndex: table.flatIndex }); + const row = findRowIn(lookup, entry.name, entry.rowType); + if (row) out.push({ node: row.node, sortIndex: row.flatIndex }); continue; } const group = layout.groups.find((candidate) => candidate.id === entry.id); @@ -354,8 +463,9 @@ function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGr schema: scope.schema, linkedServer: scope.linkedServer, vgroupId: entry.id, + vgroupKind: scope.objectType ?? "tables", isExpanded: !group.collapsed, - children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope), + children: buildVGroupChildNodes(entryChildren(entry), layout, lookup, scope), }, // -1e6 keeps every group above flat rows while preserving layout order among groups. sortIndex: -1_000_000 + groupSeq++, @@ -365,11 +475,11 @@ function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGr } /** - * Rearrange loaded table nodes under their virtual groups. Pure display-level - * transform over an already-loaded child list: table node objects are - * re-parented by reference so per-node state (isExpanded / isLoading / loaded - * children) survives projection. Members missing from the loaded page simply - * stay absent (sidebar pagination); stale names are skipped. + * Rearrange loaded container rows under their virtual groups. Pure + * display-level transform over an already-loaded child list: row node objects + * are re-parented by reference so per-node state (isExpanded / isLoading / + * loaded children) survives projection. Members missing from the loaded page + * simply stay absent (sidebar pagination); stale names are skipped. */ export function applyTableVGroupsToChildren(children: TreeNode[], layout: TableVGroupLayout | null | undefined, scope: TableVGroupScope): TreeNode[] { if (!children.length || !layout || !tableVGroupsEnabled(layout)) return children; @@ -379,27 +489,32 @@ export function applyTableVGroupsToChildren(children: TreeNode[], layout: TableV const activeLayout = layout; const flatChildren = stripTableVGroupsFromChildren(children); - const tables = new Map(); + // scope.objectType 决定哪些行参与分组;缺省 tables 维持既有表分组行为。 + const rowTypes = TABLE_VGROUP_KIND_ROW_TYPES[scope.objectType ?? "tables"]; + const rows: TableVGroupRowLookup = { byKey: new Map(), byLabel: new Map() }; const passthrough: Array<{ node: TreeNode; sortIndex: number }> = []; for (let i = 0; i < flatChildren.length; i++) { const node = flatChildren[i]!; - if (node.type === "table" && !tables.has(node.label)) tables.set(node.label, { node, flatIndex: i }); - else passthrough.push({ node, sortIndex: i }); + // 键为 (行类型, 名字):包 spec/body 同名双行可各自独立成组员;同名首行别名供历史条目按名匹配。 + if (rowTypes[node.type] && !rows.byKey.has(tableRowKey(node.type, node.label))) { + rows.byKey.set(tableRowKey(node.type, node.label), { node, flatIndex: i }); + if (!rows.byLabel.has(node.label)) rows.byLabel.set(node.label, { node, flatIndex: i }); + } else passthrough.push({ node, sortIndex: i }); } - if (!tables.size) return flatChildren; + if (!rows.byKey.size) return flatChildren; - const rootBuilt = buildVGroupRootNodes(activeLayout.order, activeLayout, tables, scope); - // Tables the layout does not reference stay ungrouped at their original slot. + const rootBuilt = buildVGroupRootNodes(activeLayout.order, activeLayout, rows, scope); + // Rows the layout does not reference stay ungrouped at their original slot. const consumed = new Set(); const consume = (nodes: TreeNode[]) => { for (const node of nodes) { - if (node.type === "table") consumed.add(node); + if (rowTypes[node.type]) consumed.add(node); else consume(node.children ?? []); } }; consume(rootBuilt.map((item) => item.node)); - for (const table of tables.values()) { - if (!consumed.has(table.node)) passthrough.push({ node: table.node, sortIndex: table.flatIndex }); + for (const row of rows.byKey.values()) { + if (!consumed.has(row.node)) passthrough.push({ node: row.node, sortIndex: row.flatIndex }); } const merged = [...rootBuilt, ...passthrough]; @@ -429,18 +544,38 @@ export function stripTableVGroupsFromChildren(children: TreeNode[]): TreeNode[] } /** - * Find the live tree node whose children this scope's layout projects. Table - * rows and their container do not always carry identical optional identity - * fields (a simple-display database node has no schema while its table rows - * do), so optional parts tolerate a missing value on either side; when several - * containers match, the one that actually holds table rows wins. + * Find the live tree node whose children this scope's layout projects. Rows + * and their container do not always carry identical optional identity fields + * (a simple-display database node has no schema while its rows do), so + * optional parts tolerate a missing value on either side; when several + * containers match, the one that actually holds member rows wins. A member + * type narrows candidates to containers of a matching kind, and so does a + * non-tables scope.objectType — without that, reprojection triggered by, say, + * a functions-layout change could fall back onto the database container and + * strip the tables grouping out of it. */ -export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGroupScope, tableName?: string): TreeNode | null { +export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGroupScope, memberLabel?: string, memberType?: string): TreeNode | null { const matchesOptional = (nodeValue: string | undefined, scopeValue: string | undefined) => nodeValue === scopeValue || !nodeValue || !scopeValue; + // 目标类别:memberType(行解析)按成员类型收敛;scope.objectType(重投影)按 + // 类别收敛;tables 作用域(缺省)也必须收敛到 tables——否则其它类别的容器会混入 + // 候选池,tables 的无成员写入(建组/改名/折叠等)可能误路由过去并抹掉其分组投影。 + const scopeKind = scope.objectType && scope.objectType !== "tables" ? scope.objectType : null; + const expectedKind = scopeKind ?? (memberType ? null : "tables"); + const memberRowTypes = memberType ? { [memberType]: true as const } : TABLE_VGROUP_KIND_ROW_TYPES[scope.objectType ?? "tables"]; const candidates: TreeNode[] = []; const walk = (list: TreeNode[]) => { for (const node of list) { - if (isTableVGroupContainerNode(node) && node.connectionId === scope.connectionId && (node.database ?? "") === (scope.database ?? "") && matchesOptional(node.catalog, scope.catalog) && matchesOptional(node.schema, scope.schema) && matchesOptional(node.linkedServer, scope.linkedServer)) { + const kind = tableVGroupKindOfContainerNode(node); + const kindMatches = expectedKind ? kind === expectedKind : kind !== null && TABLE_VGROUP_KIND_ROW_TYPES[kind][memberType!]; + if ( + kindMatches && + isTableVGroupContainerNode(node) && + node.connectionId === scope.connectionId && + (node.database ?? "") === (scope.database ?? "") && + matchesOptional(node.catalog, scope.catalog) && + matchesOptional(node.schema, scope.schema) && + matchesOptional(node.linkedServer, scope.linkedServer) + ) { candidates.push(node); } if (node.children) walk(node.children); @@ -449,11 +584,11 @@ export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGro walk(nodes); if (!candidates.length) return null; - if (tableName) { - const holder = candidates.find((node) => (node.children ?? []).some((child) => child.type === "table" && child.label === tableName)); + if (memberLabel) { + const holder = candidates.find((node) => (node.children ?? []).some((child) => memberRowTypes[child.type] && child.label === memberLabel)); if (holder) return holder; } - return candidates.find((node) => (node.children ?? []).some((child) => child.type === "table" || child.type === "table-vgroup")) ?? candidates[0]!; + return candidates.find((node) => (node.children ?? []).some((child) => memberRowTypes[child.type] || child.type === "table-vgroup")) ?? candidates[0]!; } /** @@ -482,17 +617,20 @@ export function normalizeTableVGroupLayout(value: unknown): TableVGroupLayout { const normalizeEntries = (entries: unknown): TableVGroupOrderEntry[] => { const out: TableVGroupOrderEntry[] = []; if (!Array.isArray(entries)) return out; - // 同一层里重复的表名或分组引用会投影出重复节点(渲染以 id 作 key), - // 损坏的持久化数据必须在归一化这一步收敛。 + // 同一层里重复的成员条目或分组引用会投影出重复节点(渲染以 id 作 key), + // 损坏的持久化数据必须在归一化这一步收敛。去重键含行类型:包 spec/body + // 同名双行是合法共存,不算重复。 const seenTableNames = new Set(); const seenGroupEntries = new Set(); for (const entry of entries) { if (!entry || typeof entry !== "object") continue; const candidate = entry as Partial; if (candidate.type === "table") { - if (typeof candidate.name !== "string" || !candidate.name || seenTableNames.has(candidate.name)) continue; - seenTableNames.add(candidate.name); - out.push({ type: "table", name: candidate.name }); + const rowType = typeof candidate.rowType === "string" ? candidate.rowType : undefined; + const dedupeKey = `${rowType ?? ""}\u0000${candidate.name}`; + if (typeof candidate.name !== "string" || !candidate.name || seenTableNames.has(dedupeKey)) continue; + seenTableNames.add(dedupeKey); + out.push({ type: "table", name: candidate.name, ...(rowType ? { rowType } : {}) }); } else if (candidate.type === "group" && typeof candidate.id === "string" && candidate.id && validGroupIds.has(candidate.id) && !seenGroupEntries.has(candidate.id)) { seenGroupEntries.add(candidate.id); out.push({ type: "group", id: candidate.id, children: normalizeEntries(entryChildren(candidate as TableVGroupEntry)) }); @@ -540,24 +678,76 @@ function findTableVGroupHostContainer(nodes: TreeNode[], node: TreeNode): TreeNo return walk(nodes, []); } +/** 节点到根路径上最近的「可分组容器」;投影分组行的类别由它决定。 */ +function findTableVGroupHolderContainer(nodes: TreeNode[], node: TreeNode): TreeNode | null { + const walk = (list: TreeNode[], ancestors: TreeNode[]): TreeNode | null => { + for (const item of list) { + if (item === node || item.id === node.id) { + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]!; + if (tableVGroupKindOfContainerNode(ancestor)) return ancestor; + } + return null; + } + if (item.children) { + const found = walk(item.children, [...ancestors, item]); + if (found) return found; + } + } + return null; + }; + return walk(nodes, []); +} + function tableVGroupScopeHostOf(nodes: TreeNode[], node: TreeNode): TreeNode { return isTableVGroupDisplayNode(node.type) ? (findTableVGroupHostContainer(nodes, node) ?? node) : node; } /** - * Derive the layout scope for any sidebar row: containers and group nodes own - * their fields directly, while table rows resolve to their holding container — - * in simple display mode a table row carries a schema its container lacks, so - * using the row's own fields would split reads and writes across two keys. + * Derive the layout scope for any sidebar row. Containers and projected group + * rows resolve identity fields up to their host container (simple display + * modes fill row-side schema fields the container lacks; using them would + * split reads and writes across two keys) while the container kind comes from + * the nearest groupable holder. Groupable rows resolve to the container of + * their own type; when none exists (a view row in a mixed simple list, say) + * the empty scope fails scope-key derivation so the row can never join a + * tables-kind layout. */ export function resolveTableVGroupScopeFromNode(nodes: TreeNode[], node: TreeNode | TableVGroupScope): TableVGroupScope { const row = node as Partial & TableVGroupScope; - if (row.type === "table-vgroup" || row.type?.startsWith("group-")) return tableVGroupScopeFieldsOf(tableVGroupScopeHostOf(nodes, row as TreeNode)); - if (isTableVGroupContainerNode(row as TreeNode)) return tableVGroupScopeFieldsOf(row as TreeNode); - const container = findTableVGroupContainerNode(nodes, row as TreeNode, row.type === "table" ? row.label : undefined); + const containerKind = isTableVGroupContainerNode(row as TreeNode) ? tableVGroupKindOfContainerNode(row as TreeNode) : null; + if (containerKind) return { ...tableVGroupScopeFieldsOf(tableVGroupScopeHostOf(nodes, row as TreeNode)), objectType: containerKind }; + if (row.type === "table-vgroup" || row.type?.startsWith("group-")) { + const host = tableVGroupScopeHostOf(nodes, row as TreeNode); + const holder = findTableVGroupHolderContainer(nodes, row as TreeNode); + return { ...tableVGroupScopeFieldsOf(host), objectType: (holder && tableVGroupKindOfContainerNode(holder)) || "tables" }; + } + if (row.type && isTableVGroupGroupableRowType(row.type)) { + const holder = findTableVGroupContainerNode(nodes, tableVGroupScopeFieldsOf(row as TreeNode), row.label, row.type); + // holder 子树(含投影出的分组层级)必须直接持有该行(同 id):label 在跨容器 + // 同名时会误配——表结构子组里的触发器行与库级触发器列表的行同名不同节点; + // 而「子树」而非「顶层」是因为已入组的成员行挂在 table-vgroup 行下,仍属 + // 同一容器的管辖范围(移出/换组动作就是从这里发起的)。 + const holdsRow = (nodes?: TreeNode[]): boolean => (nodes ?? []).some((n) => n.id === row.id || (n.type === "table-vgroup" && holdsRow(n.children))); + if (!holder || !holdsRow(holder.children)) return {}; + const host = tableVGroupScopeHostOf(nodes, holder); + return { ...tableVGroupScopeFieldsOf(host), objectType: tableVGroupKindOfContainerNode(holder) ?? "tables" }; + } + const container = findTableVGroupContainerNode(nodes, row as TreeNode); return tableVGroupScopeFieldsOf(container ? tableVGroupScopeHostOf(nodes, container) : (row as TreeNode)); } +/** + * Whether a drag with the given source row type may land on this container or + * projected group row. Cross-kind drops would write phantom members into the + * other kind's layout (and same-label members could evict real ones), so the + * drop target resolution has to reject them up front. + */ +export function tableVGroupDropAcceptsRow(nodes: TreeNode[], target: TreeNode, rowType: TreeNode["type"]): boolean { + const kind = target.type === "table-vgroup" ? tableVGroupKindOfContainerNode(findTableVGroupHolderContainer(nodes, target) ?? target) : tableVGroupKindOfContainerNode(target); + return !!kind && !!TABLE_VGROUP_KIND_ROW_TYPES[kind][rowType]; +} + /** * 任意深度出现分页游标都说明列表不完整:TDengine 等把末页游标嵌在 STABLE 的子表 * 分区节点下,只看顶层会把部分页当成全量,进而误删分组成员。 @@ -566,12 +756,13 @@ export function hasTableTreeLoadMore(nodes: readonly TreeNode[]): boolean { return nodes.some((node) => node.type === "load-more" || (node.children ? hasTableTreeLoadMore(node.children) : false)); } -/** 收集子树里的表行名;多收只会让回收更保守,不会误删成员。 */ -export function collectTableTreeNames(nodes: readonly TreeNode[]): Set { +/** 收集子树里可分组行名;多收只会让回收更保守,不会误删成员。 */ +export function collectTableTreeNames(nodes: readonly TreeNode[], kind: TableVGroupObjectKind = "tables"): Set { + const rowTypes = TABLE_VGROUP_KIND_ROW_TYPES[kind]; const names = new Set(); const walk = (list: readonly TreeNode[]) => { for (const node of list) { - if (node.type === "table" && node.label) names.add(node.label); + if (rowTypes[node.type] && node.label) names.add(node.label); if (node.children) walk(node.children); } }; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.tableVGroups.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.tableVGroups.spec.ts index 07d7eb0800..95f38a7ce0 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.tableVGroups.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.tableVGroups.spec.ts @@ -15,10 +15,17 @@ describe("connectionStore table virtual groups during disk reload", () => { vi.doMock("@/lib/backend/api", () => ({ loadConnections: vi.fn().mockResolvedValue([]), loadEditorSettings: vi.fn().mockResolvedValue(null), + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listTables: vi.fn().mockResolvedValue([]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), + listObjects: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), savePinnedTreeNodeIds: vi.fn().mockResolvedValue(undefined), @@ -80,4 +87,55 @@ describe("connectionStore table virtual groups during disk reload", () => { expect(api.loadConnections).toHaveBeenCalledTimes(4); expect(store.tableVGroupLayoutFor(container)).toEqual(layout); }); + + it("keeps views layouts separate from tables layouts for the same database", async () => { + const api = await import("@/lib/backend/api"); + const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup"); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const config: ConnectionConfig = { id: "pg-9733", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" }; + const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const }; + const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] }; + vi.mocked(api.loadConnections).mockResolvedValue([config]); + vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout }); + const store = useConnectionStore(); + await store.initFromDisk(); + + const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app" }; + const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews] }; + store.treeNodes[0]!.isExpanded = true; + store.treeNodes[0]!.children!.push(database); + expect(store.tableVGroupLayoutFor(groupViews)).toEqual(viewsLayout); + expect(store.tableVGroupLayoutFor(database)).toBeUndefined(); + }); + + it("keeps projected view groups through the setChildren reload path", async () => { + const api = await import("@/lib/backend/api"); + const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup"); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const config: ConnectionConfig = { id: "pg-view-1", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" }; + const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const }; + const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] }; + vi.mocked(api.loadConnections).mockResolvedValue([config]); + vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout }); + const store = useConnectionStore(); + await store.initFromDisk(); + const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app", children: [], isExpanded: true }; + const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews], isExpanded: true }; + vi.mocked(api.listTables).mockResolvedValue([ + { name: "v_pay", table_type: "VIEW", schema: "public", comment: null }, + { name: "v_other", table_type: "VIEW", schema: "public", comment: null }, + ]); + store.connectedIds = new Set([config.id]); + store.treeNodes[0]!.isExpanded = true; + store.treeNodes[0]!.children!.push(database); + + await store.refreshTreeNode(groupViews); + + // setChildren 必须用解析后的 views scope 投影:分组顶置、成员重挂、非成员原位 + const labels = groupViews.children?.map((node) => node.label) ?? []; + expect(labels[0]).toBe("支付视图"); + expect(groupViews.children![0]!.type).toBe("table-vgroup"); + expect(groupViews.children![0]!.children?.map((node) => node.label)).toEqual(["v_pay"]); + expect(labels).toContain("v_other"); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 9d95bf14be..63d5a106d0 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -84,6 +84,7 @@ import { resolveTableVGroupScopeFromNode, setTableVGroupsEnabled as setTableVGroupsEnabledOp, stripTableVGroupsFromChildren, + tableVGroupKindOfContainerNode, tableVGroupPathForTable as tableVGroupPathForTableOp, tableVGroupScopeKey, toggleTableVGroupCollapsed as toggleTableVGroupCollapsedOp, @@ -1949,7 +1950,8 @@ export const useConnectionStore = defineStore("connection", () => { persistPinnedTreeNodeIds(); } syncPinnedTreeState(children); - children = applyTableVGroupsToChildren(children, tableVGroupLayoutForNode(parent), parent); + const vgroupResolved = resolveTableVGroupScope(parent); + children = applyTableVGroupsToChildren(children, vgroupResolved ? tableVGroupLayouts.value[vgroupResolved.scopeKey] : undefined, vgroupResolved?.scope ?? parent); parent.children = markRawLeafTreeNodes(children); loadedTreeNodeChildrenIds.value.add(parent.id); syncConfirmedEmptyTreeNodeId(parent); @@ -6393,7 +6395,7 @@ export const useConnectionStore = defineStore("connection", () => { options?.onChildrenApplied?.(targetNode); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); - pruneTableVGroupStaleMembers(targetNode, children, targetNode.type === "group-tables"); + pruneTableVGroupStaleMembers(targetNode, children, tableVGroupKindOfContainerNode(targetNode) !== null); } const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; @@ -6560,6 +6562,9 @@ export const useConnectionStore = defineStore("connection", () => { setChildren(targetParent, nextChildren); if (!options?.searchFilter) { await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + // procedures/triggers 等对象组在此分支提前 return,回收必须在 return 前; + // 非分组容器由 prune 内部的类别防护挡下。 + pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null); } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; @@ -6571,9 +6576,9 @@ export const useConnectionStore = defineStore("connection", () => { setChildren(targetParent, nextChildren); if (!options?.searchFilter) { await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); - // 只认 group-tables:group-views 等分组与表共用 scope_key,其列表不是全量表; - // 分页中间态(含 load-more)由 prune 内部再挡一次。 - pruneTableVGroupStaleMembers(targetParent, nextChildren, parent.type === "group-tables"); + // 各分组容器按自身类别回收完整列表的失效成员;分页中间态(含 + // load-more)由 prune 内部再挡一次。 + pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null); } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; @@ -9045,9 +9050,22 @@ export const useConnectionStore = defineStore("connection", () => { return scopeKey ? { scope, scopeKey } : null; } - function tableVGroupLayoutForNode(node: TreeNode): TableVGroupLayout | undefined { - const resolved = resolveTableVGroupScope(node); - return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined; + /** 立即落盘全部待写布局(清防抖计时器与脏集合):initFromDisk 等整体覆盖内存布局的 + * 路径必须先调用,否则窗口内 ≤300ms 的分组编辑会被旧快照静默回滚。 */ + async function flushTableVGroupPersist(): Promise { + if (tableVGroupPersistTimer) { + clearTimeout(tableVGroupPersistTimer); + tableVGroupPersistTimer = null; + } + if (!dirtyTableVGroupScopeKeys.size) return; + const pending = [...dirtyTableVGroupScopeKeys]; + dirtyTableVGroupScopeKeys.clear(); + await Promise.all( + pending.map((key) => { + const layout = tableVGroupLayouts.value[key]; + return layout ? api.saveTableVGroups(key, layout).catch(() => {}) : Promise.resolve(); + }), + ); } function scheduleTableVGroupPersistFlush() { @@ -9063,43 +9081,45 @@ export const useConnectionStore = defineStore("connection", () => { } /** 分组节点是显示层投影:按已解析的作用域重建容器子节点,容器里不留投影副本。 */ - function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string) { - const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName); + function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string, rowType?: string) { + const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName, rowType); if (!container?.children) return; container.children = applyTableVGroupsToChildren(stripTableVGroupsFromChildren(container.children), tableVGroupLayouts.value[scopeKey], scope); } - /** 表分组成员回收:只有「本 scope 的完整表列表」才能判定成员存亡。 - * 同一 scope_key 下并存多种列表:grouped 的各对象分组(视图/函数…只含非表行)、 - * simple 的库/模式表列表、以及分页中间态。非表列表或分页结果作为依据会把有效 - * 成员误删并持久化,故列表语义由调用点声明(`completeTableList`),本函数只复核分页态。 */ + /** 分组成员回收:只有「本 scope 的完整列表」才能判定成员存亡。 + * 同一连接下并存多种列表:各分组容器(tables/views/procedures/triggers 各自的 + * 完整列表)、非分组容器列表、以及分页中间态。非本容器类别的列表或分页结果作为 + * 依据会把有效成员误删并持久化,故列表语义由调用点声明(`completeTableList`), + * 本函数复核容器类别与分页态,回收范围限定容器自身类别(objectType)。 */ function pruneTableVGroupStaleMembers(parent: TreeNode, children: TreeNode[], completeTableList: boolean) { - if (!completeTableList) return; + const kind = tableVGroupKindOfContainerNode(parent); + if (!kind || !completeTableList) return; const resolved = resolveTableVGroupScope(parent); const layout = resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined; if (!resolved || !hasTableVGroupEntries(layout)) return; if (hasTableTreeLoadMore(children)) return; - const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children)); + const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children), kind); const next = pruneTableVGroupMembersOp(layout, keepNames); if (next === layout) return; updateTableVGroupLayout(resolved.scope, resolved.scopeKey, next); } /** 写入某作用域的布局:即时重投影 + 合并 300ms 后落盘。作用域须已解析出 scopeKey。 */ - function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string) { + function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string, rowType?: string) { tableVGroupLayouts.value = { ...tableVGroupLayouts.value, [scopeKey]: nextLayout }; dirtyTableVGroupScopeKeys.add(scopeKey); scheduleTableVGroupPersistFlush(); - reprojectTableVGroupScope(scope, scopeKey, tableName); + reprojectTableVGroupScope(scope, scopeKey, tableName, rowType); } /** 分组变更的统一入口:读当前布局 → 变换 → 写回。作用域不可解析或尚无布局时跳过。 */ - function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string) { + function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string, rowType?: string) { const resolved = resolveTableVGroupScope(scope); if (!resolved) return; const current = tableVGroupLayouts.value[resolved.scopeKey]; if (!current) return; - updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName); + updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName, rowType); } function rebuildTreeNodes() { @@ -9635,6 +9655,9 @@ export const useConnectionStore = defineStore("connection", () => { await settingsStore.initEditorSettings(); if (!initFromDiskPromise) { initFromDiskPromise = (async () => { + // 整体覆盖内存布局前,先落盘窗口内未写盘的分组编辑(防止 300ms 防抖窗口 + // 内的编辑被备份轮询/重载带回的旧快照静默回滚)。 + await flushTableVGroupPersist(); const [pinnedOrder, saved, , loadedTableVGroups] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init(), api.loadTableVGroups()]); setPinnedTreeNodeOrder(pinnedOrder); await migrateTimeoutInheritance(saved); @@ -9953,8 +9976,8 @@ export const useConnectionStore = defineStore("connection", () => { deleteTableVGroups(scope: TableVGroupScope, groupIds: Iterable) { updateTableVGroupLayoutFor(scope, (layout) => deleteTableVGroupsOp(layout, groupIds)); }, - moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null) { - updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId), tableName); + moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null, rowType?: string) { + updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId, rowType), tableName, rowType); }, reorderTableVGroupEntry(scope: TableVGroupScope, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition) { updateTableVGroupLayoutFor(scope, (layout) => reorderTableVGroupEntryOp(layout, draggedEntryId, targetEntryId, position)); @@ -9965,9 +9988,9 @@ export const useConnectionStore = defineStore("connection", () => { setTableVGroupsEnabled(scope: TableVGroupScope, enabled: boolean) { updateTableVGroupLayoutFor(scope, (layout) => setTableVGroupsEnabledOp(layout, enabled)); }, - tableVGroupPathForTable(scope: TableVGroupScope, tableName: string) { + tableVGroupPathForTable(scope: TableVGroupScope, tableName: string, rowType?: string) { const resolved = resolveTableVGroupScope(scope); - return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName); + return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName, rowType); }, }; }); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f4289b3206..b153880ba9 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1467,7 +1467,7 @@ export interface SidebarLayout { order: SidebarOrderEntry[]; } -export type TableVGroupOrderEntry = { type: "group"; id: string; children?: TableVGroupOrderEntry[] } | { type: "table"; name: string }; +export type TableVGroupOrderEntry = { type: "group"; id: string; children?: TableVGroupOrderEntry[] } | { type: "table"; name: string; /** 行类型(view/procedure/…)。同名双行容器(包 spec/body、type/type-body)靠它区分成员;缺省 = 按名字匹配(历史数据与表)。 */ rowType?: string }; export interface TableVGroupLayout { version?: number; @@ -1534,6 +1534,8 @@ export interface TreeNode { savedSqlFolderId?: string; /** Set on synthetic table virtual-group container nodes. */ vgroupId?: string; + /** 投影时盖章的分组类别(tables/views/…),供拖拽落点 O(1) 类别判定。 */ + vgroupKind?: string; meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta | CustomTypeTreeMemberMeta; loadMore?: { parentId: string;