diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 5945f5ac12..109b3ec539 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -64,6 +64,7 @@ import SidebarLocateButton from "./SidebarLocateButton.vue"; import SidebarRegexToggleButton from "./SidebarRegexToggleButton.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; +import SidebarTableVGroupDialog from "./SidebarTableVGroupDialog.vue"; import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue"; import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue"; import { RecycleScroller } from "vue-virtual-scroller"; @@ -1483,6 +1484,12 @@ function topOcclusionHeightForSidebarNode(nodeId: string): number { return SIDEBAR_TREE_ROW_HEIGHT; } +/** Select and reveal a freshly created table group. */ +async function focusCreatedTableVGroup(groupId: string) { + store.selectedTreeNodeId = groupId; + await scrollToSidebarNode(groupId); +} + async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNodeScrollAlign }) { await nextTick(); @@ -1655,6 +1662,14 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb } } + // 表分组行同样是投影出的合成节点(不登记已加载子节点,上面的守卫会跳过), + // 折叠状态存在布局里,必须经布局 op 展开,否则下次投影又把它折叠回去。 + for (const node of nodePath) { + if (node.type !== "table-vgroup" || !node.vgroupId) continue; + const group = store.tableVGroupLayoutFor(node)?.groups.find((current) => current.id === node.vgroupId); + if (group?.collapsed) store.toggleTableVGroupCollapsed(node, node.vgroupId); + } + // Connection groups never register loaded tree children, so the guard above // skips them and a collapsed group keeps the target out of the visible flat // tree. Reopen them through the layout op so the expansion is persisted and @@ -2855,6 +2870,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid +
diff --git a/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue b/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue new file mode 100644 index 0000000000..5fcce8fbd8 --- /dev/null +++ b/apps/desktop/src/components/sidebar/SidebarTableVGroupDialog.vue @@ -0,0 +1,71 @@ + + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index ed16ff760c..a515539047 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -84,6 +84,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 { objectTypesForGroupNode } from "@/lib/table/tableTree"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus"; @@ -219,6 +220,13 @@ import { sidebarDangerRunningCancel, sidebarFormTarget, showDeleteConfirm, + showTableVGroupDialog, + showTableVGroupDeleteConfirm, + tableVGroupDeleteTarget, + tableVGroupName, + tableVGroupDialogScope, + tableVGroupDialogParentGroupId, + tableVGroupDialogTableNames, showDropTableConfirm, showDropTableChildObjectConfirm, showBatchDropConfirm, @@ -819,6 +827,13 @@ async function toggle(requestId = beginNavigationRequest()) { return; } + if (node.type === "table-vgroup") { + node.isExpanded = !node.isExpanded; + if (node.vgroupId) connectionStore.toggleTableVGroupCollapsed(node, node.vgroupId); + emitNodeToggled(node, wasExpanded); + return; + } + if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.children !== undefined) { node.isExpanded = node.children.length > 0 ? !node.isExpanded : false; emitNodeToggled(node, wasExpanded); @@ -1402,6 +1417,10 @@ function requestRenameSelectedNode(): boolean { startRenameGroup(); return true; } + if (activeNode.value.type === "table-vgroup" && activeNode.value.vgroupId) { + emit("request-group-rename", activeNode.value.id); + return true; + } if (activeNode.value.type === "saved-sql-file" && activeNode.value.savedSqlId) { emit("request-saved-sql-rename", activeNode.value.id); return true; @@ -1463,6 +1482,10 @@ function requestDeleteSelectedNode(): boolean { deleteConnectionGroup(); return true; } + if (activeNode.value.type === "table-vgroup") { + requestTableVGroupDelete(activeNode.value); + return true; + } if (canDropDatabase.value) { dropDatabase(); return true; @@ -5707,6 +5730,7 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { const { node, items } = context; + appendTableVGroupContainerItems(node, items); // 4. Database / Schema if (node.type === "database" || node.type === "schema") { if (isXuguSyntheticTreeNode(currentDatabaseType(), node.type, node.schema)) { @@ -6104,6 +6128,10 @@ 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)); @@ -6435,6 +6463,7 @@ function treeTableClipboardMenuItems(node: TreeNode): ContextMenuItem[] { function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolean { const { node, items } = context; + appendTableVGroupContainerItems(node, items); // 9. Group Labels (group-columns, group-tables, etc.) if (isGroupLabel(node)) { const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null; @@ -6506,7 +6535,89 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea return false; } -const sidebarMenuFactories: readonly SidebarMenuFactory[] = [buildConnectionSidebarMenu, buildDatabaseSidebarMenu, buildSpecialSidebarMenu, buildObjectSidebarMenu, buildObjectGroupSidebarMenu]; +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 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); + }, + })); + if (targetNames.some((name) => tableVGroupPathForTable(layout, name).length > 0)) { + items.push({ + label: t("tableVGroup.removeFromGroup"), + action: () => { + for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, null); + }, + }); + } + items.push({ label: "", separator: true }); + items.push({ + label: t("tableVGroup.moveToNewGroup"), + action: () => { + tableVGroupDialogScope.value = node; + tableVGroupDialogParentGroupId.value = null; + tableVGroupDialogTableNames.value = targetNames; + tableVGroupName.value = ""; + showTableVGroupDialog.value = true; + }, + icon: FolderPlus, + }); + return items; +} + +function appendTableVGroupContainerItems(node: TreeNode, items: ContextMenuItem[]) { + if (node.type !== "database" && node.type !== "schema" && node.type !== "linked-server-schema" && node.type !== "group-tables") return; + const layout = connectionStore.tableVGroupLayoutFor(node); + if (!hasTableVGroupEntries(layout)) return; + const enabled = tableVGroupsEnabled(layout); + items.push({ + label: enabled ? t("tableVGroup.disableGroups") : t("tableVGroup.enableGroups"), + action: () => connectionStore.setTableVGroupsEnabled(node, !enabled), + icon: FolderInput, + }); + items.push({ label: "", separator: true }); +} + +function buildTableVGroupSidebarMenu(context: SidebarMenuFactoryContext): boolean { + const { node, items } = context; + if (node.type !== "table-vgroup" || !node.vgroupId) return false; + const groupId = node.vgroupId; + items.push({ + label: t("tableVGroup.newSubgroup"), + action: () => { + tableVGroupDialogScope.value = node; + tableVGroupDialogParentGroupId.value = groupId; + tableVGroupDialogTableNames.value = []; + tableVGroupName.value = ""; + showTableVGroupDialog.value = true; + }, + icon: FolderPlus, + }); + items.push({ label: t("connectionGroup.renameGroup"), action: () => emit("request-group-rename", node.id), icon: Pencil, shortcut: shortcutRename }); + items.push({ + label: t("tableVGroup.deleteGroup"), + action: () => requestTableVGroupDelete(node), + icon: Trash2, + variant: "destructive" as const, + shortcut: shortcutDelete, + }); + return true; +} + +/** Open the shared confirmation before removing a group (its tables stay). */ +function requestTableVGroupDelete(node: TreeNode) { + if (node.type !== "table-vgroup" || !node.vgroupId) return; + tableVGroupDeleteTarget.value = { scope: node, groupId: node.vgroupId, name: node.label }; + showTableVGroupDeleteConfirm.value = true; +} + +const sidebarMenuFactories: readonly SidebarMenuFactory[] = [buildConnectionSidebarMenu, buildDatabaseSidebarMenu, buildSpecialSidebarMenu, buildTableVGroupSidebarMenu, buildObjectSidebarMenu, buildObjectGroupSidebarMenu]; function treeItemMenuItems(): ContextMenuItem[] { const node = activeNode.value; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 1da4ce6bed..3ad03c8ee0 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -74,6 +74,9 @@ 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 { findTreeNodeById } from "@/lib/sql/newQueryContext"; +import { resolveTableVGroupDropTarget, setTableVGroupDropTargetNodeId, tableVGroupDropTargetNodeId } from "@/lib/sidebar/sidebarTableVGroupDrag"; import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation"; import { isFocusSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { encodeSpannerResourcePath } from "@/lib/connection/spannerResourcePath"; @@ -248,6 +251,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return null; case "connection-group": return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" }; + case "table-vgroup": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-emerald-500" }; case "database": return { icon: Database, colorClass: "text-yellow-500" }; case "tablespace": @@ -1075,7 +1080,11 @@ const renameInput = ref(""); const renameInputRef = ref(); +/** Snapshot of the row being renamed: reprojecting the tree mid-rename recycles activeNode. */ +const renameTargetNode = shallowRef(null); + function startRenameGroup() { + renameTargetNode.value = activeNode.value; renameInput.value = activeNode.value.label; isRenamingGroup.value = true; emit("rename-started"); @@ -1105,6 +1114,7 @@ watch( if (activeNode.value.type === "connection-group") startRenameGroup(); else if (activeNode.value.type === "saved-sql-file") startRenameSavedSql(); else if (activeNode.value.type === "connection") startRenameConnection(); + else if (activeNode.value.type === "table-vgroup") startRenameGroup(); }, { immediate: true }, ); @@ -1126,11 +1136,17 @@ function finishRenameGroup() { // groups (issue #681). if (!isRenamingGroup.value) return; isRenamingGroup.value = false; + const target = renameTargetNode.value ?? activeNode.value; + renameTargetNode.value = null; const trimmed = renameInput.value.trim(); // An empty name cancels the rename and keeps the group as-is — never delete // here. Deleting a group is done explicitly via the context menu (issue #681). - if (!trimmed || trimmed === activeNode.value.label) return; - connectionStore.renameConnectionGroup(activeNode.value.id, trimmed); + if (!trimmed || trimmed === target.label) return; + if (target.type === "table-vgroup" && target.vgroupId) { + connectionStore.renameTableVGroup(target, target.vgroupId, trimmed); + return; + } + connectionStore.renameConnectionGroup(target.id, trimmed); } async function finishRenameSavedSql() { @@ -1193,6 +1209,19 @@ const { return; } + if (dragState.draggedType === "table-vgroup") { + const draggedGroupId = tableVGroupIdFromNodeId(draggedId); + const targetGroupId = tableVGroupIdFromNodeId(targetId); + // 落点回调是模块级单例,activeNode 未必是同容器的落点行,须用 targetId 现查。 + const targetNode = findTreeNodeById(connectionStore.treeNodes, targetId); + if (draggedGroupId && targetGroupId && targetNode) connectionStore.reorderTableVGroupEntry(targetNode, draggedGroupId, targetGroupId, position); + return; + } + + // 分组行只在拖动分组自身时才是合法落点:否则 targetId 不在侧边栏布局里, + // reorderSidebarEntries 找不到目标会把连接追加到根列表末尾。 + if (tableVGroupIdFromNodeId(targetId)) return; + // If the grabbed row is part of a multi-selection, move all selected rows // together; otherwise just the grabbed one (issue #681). const selected = connectionStore.selectedTreeNodeIds; @@ -1202,7 +1231,7 @@ const { const canReorderTreeNode = computed(() => { if (props.reorderDisabled) return false; - return activeNode.value.type === "connection" || activeNode.value.type === "connection-group"; + return activeNode.value.type === "connection" || activeNode.value.type === "connection-group" || activeNode.value.type === "table-vgroup"; }); function isPinnedOrderDrag(): boolean { @@ -1211,7 +1240,7 @@ function isPinnedOrderDrag(): boolean { const dragVisual = computed(() => { const targetId = isPinnedOrderDrag() ? pinnedSortKey() : activeNode.value.id; - const isDropTarget = isPinnedOrderDrag() ? connectionStore.isPinnedTreeNodeReorderTarget(pinnedSortKey()) : activeNode.value.type === "connection" || activeNode.value.type === "connection-group"; + const isDropTarget = isPinnedOrderDrag() ? connectionStore.isPinnedTreeNodeReorderTarget(pinnedSortKey()) : activeNode.value.type === "connection" || activeNode.value.type === "connection-group" || activeNode.value.type === "table-vgroup"; return { isDropTarget, @@ -1346,6 +1375,10 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null { function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) { draggingTableReferencePayload = payload; + // 分组成员名单只收真实表:视图/物化视图投影不识别,入组会产生隐形脏数据。 + vgroupDragTableNames = selectedTableVGroupMoveTargets(activeNode.value, selectedTreeNodesInVisibleOrder()) + .filter((node) => node.type === "table") + .map((node) => node.label); setActiveTableReferencePayload(payload); document.getSelection()?.removeAllRanges(); referenceDragFeedback = beginTableReferenceDragFeedback(tableReferenceDragLabel(payload)); @@ -1355,6 +1388,8 @@ function finishTableReferenceDrag() { clearActiveTableReferencePayload(draggingTableReferencePayload); pendingTableReferenceDrag = null; draggingTableReferencePayload = null; + vgroupDragTableNames = []; + setTableVGroupDropTargetNodeId(null); referenceDragFeedback?.end(); referenceDragFeedback = null; window.dispatchEvent(createTableReferenceDragEndEvent()); @@ -1362,6 +1397,14 @@ function finishTableReferenceDrag() { document.removeEventListener("mouseup", onTableReferenceMouseUp, true); } +/** 本次拖拽要移动进分组的表名(拖拽开始时按选中区解析,见 startTableReferenceDrag)。 */ +let vgroupDragTableNames: string[] = []; + +function tableVGroupDropTargetFor(payload: QueryEditorTableReferencePayload, event: MouseEvent) { + if (!vgroupDragTableNames.length) return null; + return resolveTableVGroupDropTarget(event.clientX, event.clientY, connectionStore.treeNodes, payload); +} + function onTableReferenceMouseMove(event: MouseEvent) { if (!pendingTableReferenceDrag && !draggingTableReferencePayload) return; if (pendingTableReferenceDrag && !draggingTableReferencePayload) { @@ -1374,6 +1417,7 @@ function onTableReferenceMouseMove(event: MouseEvent) { event.preventDefault(); document.getSelection()?.removeAllRanges(); referenceDragFeedback?.update(event.clientX, event.clientY); + setTableVGroupDropTargetNodeId(tableVGroupDropTargetFor(draggingTableReferencePayload, event)?.node.id ?? null); // 仅查询编辑器消费 hover 光标线事件;AI 面板不监听。命中判定含覆盖层拦截时的几何回退。 if (isOverSqlEditorTarget(event.clientX, event.clientY)) { window.dispatchEvent(createTableReferenceHoverEvent({ clientX: event.clientX, clientY: event.clientY })); @@ -1385,15 +1429,20 @@ function onTableReferenceMouseUp(event: MouseEvent) { const payload = draggingTableReferencePayload; if (payload) { suppressNextTableReferenceClick = true; - const target = document.elementFromPoint(event.clientX, event.clientY); - if (target instanceof Element && target.closest(`[data-query-editor-root], ${AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR}`)) { - window.dispatchEvent( - createTableReferenceDropEvent({ - payload, - clientX: event.clientX, - clientY: event.clientY, - }), - ); + const dropTarget = tableVGroupDropTargetFor(payload, event); + if (dropTarget) { + for (const tableName of vgroupDragTableNames) connectionStore.moveTableToVGroup(dropTarget.node, tableName, dropTarget.groupId); + } 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}`)) { + window.dispatchEvent( + createTableReferenceDropEvent({ + payload, + clientX: event.clientX, + clientY: event.clientY, + }), + ); + } } } finishTableReferenceDrag(); @@ -1566,7 +1615,6 @@ function onKeydown(event: KeyboardEvent) { rowWidthClass, { 'group/sidebar-row': true, - 'ring-1 ring-primary/50 bg-primary/5': dragVisual.showInside, 'opacity-50': dragVisual.dragging, 'tree-item-connection-tint': connectionColor, 'hover:bg-accent': node.type !== 'connection', @@ -1574,6 +1622,7 @@ function onKeydown(event: KeyboardEvent) { 'tree-item-active': selectionVisual.rowSelected, 'tree-item-active--selection-set': selectionVisual.usesSelectionSetHighlight && selectionVisual.rowSelected, 'tree-item-highlight': highlighted, + 'ring-1 ring-primary/50 bg-primary/5': dragVisual.showInside || tableVGroupDropTargetNodeId === node.id, }, ]" :tabindex="selectionVisual.selected || selectionVisual.multiSelected ? 0 : -1" diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 5a3c52dd12..b6b35881e7 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -5,6 +5,7 @@ import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createData import type { DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin"; import type { AuthorizationPlan, AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan"; import type { MongoCreateIndexForm, MongoIndexRow } from "@/lib/sidebar/mongoCollectionMutation"; +import type { TableVGroupScope } from "@/lib/table/tableVGroup"; export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string }; type ConnectionDeleteTarget = TreeNode & { connectionId: string }; @@ -20,6 +21,15 @@ export const sidebarFormTarget = shallowRef(null); export const connectionDeleteTargetSnapshot = ref([]); export const connectionGroupDeleteTargetSnapshot = ref([]); 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 tableVGroupDialogParentGroupId = ref(null); +export const tableVGroupDialogTableNames = ref([]); +export const showTableVGroupDeleteConfirm = ref(false); +/** Scope + group snapshot for the table vgroup delete confirmation. */ +export const tableVGroupDeleteTarget = shallowRef<{ scope: TableVGroupScope; groupId: string; name: string } | null>(null); export const showDeleteConfirm = ref(false); export const showDropTableConfirm = ref(false); export const showDropTableChildObjectConfirm = ref(false); @@ -235,6 +245,8 @@ const openFlags = [ showCompileErrorDialog, showDeleteGroupConfirm, showMoveToNewGroupDialog, + showTableVGroupDialog, + showTableVGroupDeleteConfirm, ]; export function resetSidebarTreeDialogState() { @@ -266,4 +278,7 @@ export function resetSidebarTreeDialogState() { connectionDeleteTargetSnapshot.value = []; connectionGroupDeleteTargetSnapshot.value = []; deleteConnectionsWithGroup.value = false; + tableVGroupDialogScope.value = null; + tableVGroupDialogParentGroupId.value = null; + tableVGroupDialogTableNames.value = []; } diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 89d58b36a4..aedb216d40 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2634,6 +2634,19 @@ export default withEnglishFallback({ cancel: "Ləğv et", refresh: "Yenidən təhlil et", }, + tableVGroup: { + moveToGroup: "Cədvəl qrupuna 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", + }, connectionGroup: { createGroup: "Yeni qrup", group: "Qrup", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 87e8652cc9..109530c88b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2796,6 +2796,19 @@ export default { cancel: "Cancel", refresh: "Analyze Again", }, + tableVGroup: { + moveToGroup: "Move to Table 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", + }, connectionGroup: { createGroup: "New Group", group: "Group", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4fb713f764..7aef40409a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2694,6 +2694,19 @@ export default withEnglishFallback({ cancel: "Cancelar", refresh: "Analizar de nuevo", }, + tableVGroup: { + moveToGroup: "Mover a grupo de tablas", + 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", + }, connectionGroup: { createGroup: "Nuevo grupo", group: "Grupo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9fcc008024..40cce8056f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2692,6 +2692,19 @@ export default withEnglishFallback({ cancel: "Annulla", refresh: "Analizza di Nuovo", }, + tableVGroup: { + moveToGroup: "Sposta nel gruppo tabelle", + 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", + }, connectionGroup: { createGroup: "Nuovo Gruppo", group: "Gruppo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 29bc3bf5e7..456e133394 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2719,6 +2719,19 @@ export default withEnglishFallback({ cancel: "キャンセル", refresh: "再分析", }, + tableVGroup: { + moveToGroup: "テーブルグループへ移動", + moveToNewGroup: "新規グループを作成して移動", + removeFromGroup: "グループから外す", + newGroupDefault: "新規グループ", + newSubgroup: "新規サブグループ", + deleteGroup: "グループを削除", + disableGroups: "テーブルグループを非表示", + enableGroups: "テーブルグループを表示", + deleteGroupConfirmTitle: "テーブルグループの削除", + deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内のテーブルは一覧に戻り、データベースは変更されません。", + groupDeleted: "テーブルグループを削除しました", + }, connectionGroup: { createGroup: "新しいグループ", group: "グループ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e8dd84f877..4dbdae41ba 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2602,6 +2602,19 @@ export default withEnglishFallback({ cancel: "취소", refresh: "다시 분석", }, + tableVGroup: { + moveToGroup: "테이블 그룹으로 이동", + moveToNewGroup: "새 그룹 만들고 이동", + removeFromGroup: "그룹에서 제거", + newGroupDefault: "새 그룹", + newSubgroup: "새 하위 그룹", + deleteGroup: "그룹 삭제", + disableGroups: "테이블 그룹 숨기기", + enableGroups: "테이블 그룹 표시", + deleteGroupConfirmTitle: "테이블 그룹 삭제", + deleteGroupConfirmMessage: '"{name}" 그룹을 삭제할까요? 그룹의 테이블은 목록으로 돌아가며 데이터베이스는 변경되지 않습니다.', + groupDeleted: "테이블 그룹을 삭제했습니다", + }, connectionGroup: { createGroup: "새 그룹", group: "그룹", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 38459d060e..8c2fa882b2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2694,6 +2694,19 @@ export default withEnglishFallback({ cancel: "Cancelar", refresh: "Analisar Novamente", }, + tableVGroup: { + moveToGroup: "Mover para grupo de tabelas", + 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", + }, connectionGroup: { createGroup: "Novo Grupo", group: "Grupo", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 1cd8750cb5..5ad480df4f 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2612,6 +2612,19 @@ export default withEnglishFallback({ cancel: "İptal", refresh: "Yeniden Analiz Et", }, + tableVGroup: { + moveToGroup: "Tablo grubuna 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", + }, connectionGroup: { createGroup: "Yeni Grup", group: "Grup", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3db681f071..2409cd9d70 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2710,6 +2710,19 @@ export default withEnglishFallback({ cancel: "取消读取", refresh: "重新分析", }, + tableVGroup: { + moveToGroup: "移动到表分组", + moveToNewGroup: "新建分组并移入", + removeFromGroup: "移出分组", + newGroupDefault: "新建分组", + newSubgroup: "新建子分组", + deleteGroup: "删除分组", + disableGroups: "隐藏表分组", + enableGroups: "显示表分组", + deleteGroupConfirmTitle: "删除表分组", + deleteGroupConfirmMessage: "确定删除分组「{name}」?分组内的表会回到表列表,数据库不受影响。", + groupDeleted: "表分组已删除", + }, connectionGroup: { createGroup: "新建分组", group: "分组", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2148a7582e..fab0bab0d7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2695,6 +2695,19 @@ export default withEnglishFallback({ cancel: "取消讀取", refresh: "重新分析", }, + tableVGroup: { + moveToGroup: "移動到表分組", + moveToNewGroup: "新建分組並移入", + removeFromGroup: "移出分組", + newGroupDefault: "新建分組", + newSubgroup: "新建子分組", + deleteGroup: "刪除分組", + disableGroups: "隱藏表分組", + enableGroups: "顯示表分組", + deleteGroupConfirmTitle: "刪除表分組", + deleteGroupConfirmMessage: "確定刪除分組「{name}」?分組內的資料表會回到清單,資料庫不受影響。", + groupDeleted: "表分組已刪除", + }, connectionGroup: { createGroup: "建立群組", group: "群組", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index fb09b096ce..b89c7571c5 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -896,6 +896,9 @@ export const getAppSupportInfo = forward("getAppSupportInfo"); // Layout export const saveSidebarLayout = forward("saveSidebarLayout"); export const loadSidebarLayout = forward("loadSidebarLayout"); +export const saveTableVGroups = forward("saveTableVGroups"); +export const loadTableVGroups = forward("loadTableVGroups"); +export const deleteTableVGroupsForConnection = forward("deleteTableVGroupsForConnection"); // --------------------------------------------------------------------------- // Re-export all types from tauri.ts (shared between both backends) diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 34179b3689..760653eec9 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -44,6 +44,7 @@ import type { JdbcMavenBundleInfo, JdbcPluginStatus, SidebarLayout, + TableVGroupLayout, SavedSqlFile, SavedSqlFolder, SavedSqlLibrary, @@ -5088,6 +5089,18 @@ export async function loadSidebarLayout(): Promise { return get("/api/layout/sidebar"); } +export async function saveTableVGroups(scopeKey: string, layout: TableVGroupLayout): Promise { + return post("/api/layout/table-vgroups", { scopeKey, layout }); +} + +export async function loadTableVGroups(): Promise> { + return get("/api/layout/table-vgroups"); +} + +export async function deleteTableVGroupsForConnection(connectionId: string): Promise { + return del(`/api/layout/table-vgroups/connection/${encodeURIComponent(connectionId)}`); +} + export async function refreshConnections(): Promise { // Web mode doesn't maintain persistent connection pools - no-op } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 3be872f669..722145e02d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -101,6 +101,7 @@ import type { PluginRollbackResult, PluginTrustedKey, PluginUiAssetPayload, + TableVGroupLayout, } from "@/types/database"; import type { DataGridColumnDistinctValuesSqlOptions, @@ -2675,6 +2676,18 @@ export async function loadSidebarLayout(): Promise { + return invoke("save_table_vgroups", { scopeKey, layout }); +} + +export async function loadTableVGroups(): Promise> { + return invoke("load_table_vgroups"); +} + +export async function deleteTableVGroupsForConnection(connectionId: string): Promise { + return invoke("delete_table_vgroups_for_connection", { connectionId }); +} + // --- Updates --- export interface UpdateInfo { current_version: string; diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index 9660bef72c..ed4a20c0a4 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -1,6 +1,7 @@ import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database"; import { createSidebarLabelMatcher, matchSidebarLabel, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; +import { stripTableVGroupsFromChildren } from "@/lib/table/tableVGroup"; const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]); const hiddenSearchNodeTypes = new Set(["user-admin", "dameng-job-admin"]); @@ -87,15 +88,18 @@ export function filterLocallySearchedTables(nodes: TreeNode[], options: { enable // Table comments participate in matching, mirroring the global sidebar // search and the backend's name-or-comment metadata filter. const entryMatches = (name: string, comment?: string | null) => !!matchSidebarLabel(name, query) || (!!comment && !!matchSidebarLabel(comment, query)); + // Grouped tables are flattened before matching so virtual groups stay + // transparent during local table search. + const flatChildren = stripTableVGroupsFromChildren(children); const matchingChildren = indexed === null - ? children.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment)) + ? flatChildren.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment)) : indexed ? reuseLiveSidebarTreeNodes( buildSidebarIndexedTableNodes({ parentNodeId: node.id, nodeType: node.type, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, entries: indexed.filter((entry) => entryMatches(entry.name, entry.comment)) }), - children, + flatChildren, ) - : children.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment)); + : flatChildren.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment)); return { ...node, children: matchingChildren }; }); } diff --git a/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts b/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts new file mode 100644 index 0000000000..3a3f723f57 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarTableVGroupDrag.ts @@ -0,0 +1,44 @@ +import { shallowRef } from "vue"; +import type { TreeNode } from "@/types/database"; +import { findTreeNodeById } from "@/lib/sql/newQueryContext"; +import { isTableVGroupContainerNode } from "@/lib/table/tableVGroup"; + +/** + * Shared drop feedback for dragging a table row onto a virtual group row. + * The dragged row (any TreeItem) publishes the row under the pointer so the + * hovered group can highlight itself; the pointer's own TreeItem applies the + * membership change on release. + */ +export const tableVGroupDropTargetNodeId = shallowRef(null); + +export function setTableVGroupDropTargetNodeId(nodeId: string | null) { + if (tableVGroupDropTargetNodeId.value !== nodeId) tableVGroupDropTargetNodeId.value = nodeId; +} + +export interface TableVGroupDropTarget { + node: TreeNode; + groupId: string | null; +} + +/** + * Resolve the group (or "ungroup" container) a dragged table is released on. + * Scans the whole element stack: the drag feedback chip and sticky headers sit + * 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 { + // 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) { + const row = element.closest("[data-node-id]"); + const nodeId = row?.getAttribute("data-node-id"); + if (!nodeId) continue; + const node = findTreeNodeById(treeNodes, nodeId); + if (!node || node.connectionId !== source.connectionId || (node.database ?? "") !== (source.database ?? "")) continue; + // 跨 schema:双方都声明且不同 → 拒绝(任一方未知则容忍,交给 database 校验兜底)。 + if (node.schema && source.schema && node.schema !== source.schema) continue; + if (node.type === "table-vgroup" && node.vgroupId) return { node, groupId: node.vgroupId }; + if (isTableVGroupContainerNode(node)) return { node, groupId: null }; + } + return null; +} diff --git a/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts b/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts new file mode 100644 index 0000000000..48c2f4b778 --- /dev/null +++ b/apps/desktop/src/lib/table/__tests__/tableVGroup.spec.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import type { TreeNode } from "@/types/database"; +import { + applyTableVGroupsToChildren, + collectTableTreeNames, + createTableVGroup, + deleteTableVGroups, + emptyTableVGroupLayout, + hasTableTreeLoadMore, + moveTableToVGroup, + normalizeTableVGroupLayout, + pruneTableVGroupMembers, + reorderTableVGroupEntry, + resolveTableVGroupScopeFromNode, + selectedTableVGroupMoveTargets, + stripTableVGroupsFromChildren, + tableVGroupDestinationRows, + tableVGroupPathForTable, + tableVGroupScopeKey, + toggleTableVGroupCollapsed, +} from "@/lib/table/tableVGroup"; + +const SCOPE = { connectionId: "conn-1", database: "db1" }; + +function tableNode(name: string): TreeNode { + return { id: `table:${name}`, label: name, type: "table", connectionId: "conn-1", database: "db1" }; +} + +function otherNode(id: string): TreeNode { + return { id, label: id, type: "load-more" }; +} + +function groupIds(nodes: TreeNode[]): string[] { + return nodes.filter((node) => node.type === "table-vgroup").map((node) => node.id); +} + +describe("tableVGroup layout ops", () => { + it("moves a table into a group and back to the ungrouped root", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + const moved = moveTableToVGroup(created.layout, "t_order", created.groupId); + expect(moved.order).toEqual([{ type: "group", id: created.groupId, children: [{ type: "table", name: "t_order" }] }]); + + const removed = moveTableToVGroup(moved, "t_order", null); + expect(removed.order).toEqual([ + { type: "group", id: created.groupId, children: [] }, + { type: "table", name: "t_order" }, + ]); + }); + + it("creates nested subgroups under an existing group", () => { + const parent = createTableVGroup(emptyTableVGroupLayout(), "父分组"); + const child = createTableVGroup(parent.layout, "子分组", parent.groupId); + const rows = tableVGroupDestinationRows(child.layout); + expect(rows.map((row) => row.path)).toEqual([["父分组"], ["父分组", "子分组"]]); + }); + + it("deletes groups and flattens nested members back to the parent level", () => { + const outer = createTableVGroup(emptyTableVGroupLayout(), "外层"); + const inner = createTableVGroup(outer.layout, "内层", outer.groupId); + let layout = moveTableToVGroup(inner.layout, "t_a", inner.groupId); + layout = moveTableToVGroup(layout, "t_b", outer.groupId); + + const deleted = deleteTableVGroups(layout, [outer.groupId]); + expect(deleted.groups).toEqual([]); + expect(deleted.order).toEqual([ + { type: "table", name: "t_a" }, + { type: "table", name: "t_b" }, + ]); + }); + + it("toggles group collapsed state", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "分组"); + const toggled = toggleTableVGroupCollapsed(created.layout, created.groupId); + expect(toggled.groups[0]?.collapsed).toBe(true); + expect(toggleTableVGroupCollapsed(toggled, created.groupId).groups[0]?.collapsed).toBe(false); + }); + + it("reorders a group into another group and refuses cycles", () => { + const a = createTableVGroup(emptyTableVGroupLayout(), "A"); + const b = createTableVGroup(a.layout, "B"); + const nested = reorderTableVGroupEntry(b.layout, a.groupId, b.groupId, "inside"); + expect(nested.order.map((entry) => (entry.type === "group" ? entry.id : entry.name))).toEqual([b.groupId]); + expect(findGroupChildren(nested, b.groupId)).toEqual([a.groupId]); + + // Moving B under its own descendant A must be a no-op. + const cyclic = reorderTableVGroupEntry(nested, b.groupId, a.groupId, "inside"); + expect(cyclic).toBe(nested); + }); + + it("resolves the group path for a table", () => { + const parent = createTableVGroup(emptyTableVGroupLayout(), "父"); + const child = createTableVGroup(parent.layout, "子", parent.groupId); + const layout = moveTableToVGroup(child.layout, "t_order", child.groupId); + expect(tableVGroupPathForTable(layout, "t_order")).toEqual([parent.groupId, child.groupId]); + expect(tableVGroupPathForTable(layout, "t_missing")).toEqual([]); + }); + + it("builds a stable scope key", () => { + expect(tableVGroupScopeKey({ connectionId: "c", database: "db", schema: "s" })).toBe("c\u0000\u0000\u0000db\u0000s"); + expect(tableVGroupScopeKey({ connectionId: "c", database: "db" })).toBe("c\u0000\u0000\u0000db\u0000"); + expect(tableVGroupScopeKey({ connectionId: "c" })).toBeNull(); + }); + + it("moves the whole selection only when every selected table shares the scope", () => { + const a = tableNode("t_a"); + const b = tableNode("t_b"); + const otherDb = { ...tableNode("t_c"), connectionId: "conn-2" }; + const view = { ...tableNode("t_v"), type: "view" as const }; + + expect(selectedTableVGroupMoveTargets(a, [a, b])).toEqual([a, b]); + // 混入其他库或非表节点时回退为只移动右键的那一张。 + expect(selectedTableVGroupMoveTargets(a, [a, otherDb])).toEqual([a]); + expect(selectedTableVGroupMoveTargets(a, [a, view])).toEqual([a]); + // 右键节点不在选区内时只移动右键节点。 + expect(selectedTableVGroupMoveTargets(a, [b])).toEqual([a]); + }); +}); + +describe("applyTableVGroupsToChildren", () => { + it("re-applying to already projected children does not duplicate group containers", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + const layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + const first = applyTableVGroupsToChildren([tableNode("t_order"), tableNode("t_other")], layout, SCOPE); + const second = applyTableVGroupsToChildren(first, layout, SCOPE); + expect(groupIds(second)).toEqual(groupIds(first)); + expect(second.filter((node) => node.type === "table").map((node) => node.label)).toEqual(["t_other"]); + }); + + it("projects groups at the top of the container and keeps other rows in place", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + let layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + layout = moveTableToVGroup(layout, "t_pay", created.groupId); + + const tUser = tableNode("t_user"); + const tOrder = tableNode("t_order"); + const tPay = tableNode("t_pay"); + const loadMore = otherNode("load-more"); + const projected = applyTableVGroupsToChildren([tUser, tOrder, tPay, loadMore], layout, SCOPE); + + expect(projected).toHaveLength(3); + expect(groupIds(projected)).toEqual([`table-vgroup:${created.groupId}`]); + expect(projected[0]!.type).toBe("table-vgroup"); + const groupNode = projected[0]!; + expect(groupNode.isExpanded).toBe(true); + expect(groupNode.children?.map((node) => node.label)).toEqual(["t_order", "t_pay"]); + // Table nodes keep their object identity so loaded state survives projection. + expect(groupNode.children?.[0]).toBe(tOrder); + expect(projected[2]).toBe(loadMore); + }); + + it("skips unknown members and keeps ungrouped tables when disabled", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "空组"); + const layout = { ...moveTableToVGroup(created.layout, "t_ghost", created.groupId), enabled: false }; + + const tUser = tableNode("t_user"); + const projected = applyTableVGroupsToChildren([tUser], layout, SCOPE); + expect(projected).toEqual([tUser]); + }); + + it("returns the input untouched for empty layouts or empty children", () => { + const children = [tableNode("t_user")]; + expect(applyTableVGroupsToChildren(children, emptyTableVGroupLayout(), SCOPE)).toBe(children); + expect(applyTableVGroupsToChildren([], emptyTableVGroupLayout(), SCOPE)).toEqual([]); + expect(applyTableVGroupsToChildren(children, undefined, SCOPE)).toBe(children); + }); + + it("strips projected groups back into a flat list preserving table identity", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + const layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + const tOrder = tableNode("t_order"); + const projected = applyTableVGroupsToChildren([tOrder], layout, SCOPE); + const stripped = stripTableVGroupsFromChildren(projected); + expect(stripped.map((node) => node.label)).toEqual(["t_order"]); + expect(stripped[0]).toBe(tOrder); + }); +}); + +describe("resolveTableVGroupScopeFromNode", () => { + const databaseNode: TreeNode = { id: "conn-1:main", label: "main", type: "database", connectionId: "conn-1", database: "main" }; + const tablesGroup: TreeNode = { id: "conn-1:main:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "main", schema: "main", children: [] }; + const row: TreeNode = { ...tableNode("t_order"), database: "main", schema: "main" }; + databaseNode.children = [tablesGroup]; + tablesGroup.children = [row]; + + it("derives the same scope for a display group and its host container", () => { + const fromGroup = resolveTableVGroupScopeFromNode([databaseNode], tablesGroup); + const fromHost = resolveTableVGroupScopeFromNode([databaseNode], databaseNode); + expect(fromGroup).toEqual(fromHost); + // sqlite 语义下分组节点的 schema 填的是 effectiveSchema,不能泄漏进 scope。 + expect(fromGroup.schema).toBeUndefined(); + }); + + it("derives a table row's scope from the host container, not the display group", () => { + expect(resolveTableVGroupScopeFromNode([databaseNode], row)).toEqual(resolveTableVGroupScopeFromNode([databaseNode], databaseNode)); + }); + + it("derives a projected group row's scope from the host container", () => { + const groupRow: TreeNode = { id: "table-vgroup:g1", label: "回归组", type: "table-vgroup", vgroupId: "g1", connectionId: "conn-1", database: "main", schema: "main", children: [] }; + tablesGroup.children = [row, groupRow]; + expect(resolveTableVGroupScopeFromNode([databaseNode], groupRow)).toEqual(resolveTableVGroupScopeFromNode([databaseNode], databaseNode)); + }); +}); + +describe("table tree helpers", () => { + it("detects pagination cursors nested under table nodes", () => { + const stable: TreeNode = { id: "stable", label: "st", type: "stable", children: [{ id: "lm", label: "more", type: "load-more" }] }; + expect(hasTableTreeLoadMore([stable])).toBe(true); + expect(hasTableTreeLoadMore([tableNode("t_order")])).toBe(false); + }); + + it("collects table names from nested children", () => { + const stable: TreeNode = { id: "stable", label: "st", type: "stable", children: [tableNode("t_child")] }; + const names = collectTableTreeNames([tableNode("t_order"), stable]); + expect([...names].sort()).toEqual(["t_child", "t_order"]); + }); +}); + +describe("normalizeTableVGroupLayout", () => { + it("keeps valid layouts at the current version and drops unknown fields", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + const moved = moveTableToVGroup(created.layout, "t_order", created.groupId); + const poisoned = JSON.parse(JSON.stringify(moved)) as Record & { mode?: string; version?: number }; + poisoned.mode = "exclusive"; + poisoned.version = 99; + + const normalized = normalizeTableVGroupLayout(poisoned); + expect(normalized.version).toBe(1); + expect(normalized).not.toHaveProperty("mode"); + expect(normalized.order).toEqual(moved.order); + expect(normalized.groups).toEqual(moved.groups); + }); + + it("returns an empty current-version layout for malformed payloads", () => { + expect(normalizeTableVGroupLayout(null)).toEqual(emptyTableVGroupLayout()); + expect(normalizeTableVGroupLayout({ groups: "nope" })).toEqual(emptyTableVGroupLayout()); + }); + + it("deduplicates repeated table names and group references within a level", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + const layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + const withDuplicates = { + ...layout, + order: [ + { + type: "group", + id: created.groupId, + children: [ + { type: "table", name: "t_order" }, + { type: "table", name: "t_order" }, + ], + }, + { type: "group", id: created.groupId, children: [] }, + ], + }; + const normalized = normalizeTableVGroupLayout(withDuplicates); + expect(normalized.order).toEqual([{ type: "group", id: created.groupId, children: [{ type: "table", name: "t_order" }] }]); + }); +}); + +describe("pruneTableVGroupMembers", () => { + it("drops members missing from a complete load but keeps the group itself", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "订单域"); + let layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + layout = moveTableToVGroup(layout, "t_gone", created.groupId); + + const pruned = pruneTableVGroupMembers(layout, new Set(["t_order"])); + expect(pruned).not.toBe(layout); + expect(pruned.order).toEqual([{ type: "group", id: created.groupId, children: [{ type: "table", name: "t_order" }] }]); + // 无失效成员时原样返回,不触发无谓的持久化。 + expect(pruneTableVGroupMembers(pruned, new Set(["t_order", "t_new"]))).toBe(pruned); + }); +}); + +function findGroupChildren(layout: ReturnType, groupId: string): string[] { + const visit = (entries: typeof layout.order): string[] => { + for (const entry of entries) { + if (entry.type !== "group") continue; + if (entry.id === groupId) return (entry.children ?? []).flatMap((child) => (child.type === "group" ? [child.id] : [])); + const found = visit(entry.children ?? []); + if (found.length) return found; + } + return []; + }; + return visit(layout.order); +} diff --git a/apps/desktop/src/lib/table/tableVGroup.ts b/apps/desktop/src/lib/table/tableVGroup.ts new file mode 100644 index 0000000000..d5044adf45 --- /dev/null +++ b/apps/desktop/src/lib/table/tableVGroup.ts @@ -0,0 +1,608 @@ +import type { ConnectionGroup, TableVGroupLayout, TableVGroupOrderEntry, TreeNode } from "@/types/database"; +import { uuid } from "@/lib/common/utils"; + +export type { TableVGroupLayout, TableVGroupOrderEntry }; + +/** + * Table virtual groups (issue #3470): a purely local, per-scope arrangement of + * table names into nested folders, mirroring the connection sidebar layout + * model (`SidebarLayout`) but keyed by table name instead of connection id. + * Layouts never touch database metadata — unknown table names are tolerated so + * groups can reference tables outside the currently loaded sidebar page. + */ + +export type TableVGroupDropPosition = "before" | "after" | "inside"; + +export interface TableVGroupScope { + connectionId?: string; + catalog?: string; + database?: string; + schema?: string; + linkedServer?: string; +} + +type TableVGroupEntry = Extract; + +export function emptyTableVGroupLayout(): TableVGroupLayout { + return { version: TABLE_VGROUP_LAYOUT_VERSION, groups: [], order: [] }; +} + +export function hasTableVGroupEntries(layout: TableVGroupLayout | null | undefined): layout is TableVGroupLayout { + return !!layout && (layout.groups.length > 0 || layout.order.length > 0); +} + +/** Vgroups stay visible unless the user explicitly hid them. */ +export function tableVGroupsEnabled(layout: TableVGroupLayout | null | undefined): boolean { + return hasTableVGroupEntries(layout) && layout.enabled !== false; +} + +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 TABLE_VGROUP_NODE_ID_PREFIX = "table-vgroup:"; + +const TABLE_VGROUP_LAYOUT_VERSION = 1; + +export function tableVGroupNodeId(groupId: string): string { + return `${TABLE_VGROUP_NODE_ID_PREFIX}${groupId}`; +} + +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"]); + +/** 行的身份字段即 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); +} + +/** + * Selection semantics for "move to group", mirroring the connection tree: when + * several rows are selected and the right-clicked table 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. + */ +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 ?? ""); + const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id); + if (selectedNodes.length > 1 && selectedContainsCurrent && selectedNodes.every(inScope)) return [...selectedNodes]; + return [currentNode]; +} + +function entryChildren(entry: TableVGroupEntry): TableVGroupOrderEntry[] { + return entry.children ?? []; +} + +function cloneEntries(entries: TableVGroupOrderEntry[]): TableVGroupOrderEntry[] { + return entries.map((entry) => (entry.type === "group" ? { type: "group", id: entry.id, children: cloneEntries(entryChildren(entry)) } : { ...entry })); +} + +function findGroupEntry(entries: TableVGroupOrderEntry[], groupId: string): TableVGroupEntry | null { + for (const entry of entries) { + if (entry.type !== "group") continue; + if (entry.id === groupId) return entry; + const found = findGroupEntry(entryChildren(entry), groupId); + if (found) return found; + } + return null; +} + +function containsGroup(entry: TableVGroupOrderEntry, groupId: string): boolean { + if (entry.type !== "group") return false; + if (entry.id === groupId) return true; + 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)); +} + +function expandGroup(layout: TableVGroupLayout, groupId: string): TableVGroupLayout { + return { + ...layout, + groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)), + }; +} + +export function moveTableToVGroup(layout: TableVGroupLayout, tableName: string, targetGroupId: string | null): TableVGroupLayout { + if (!hasTableVGroupEntries(layout)) return layout; + const order = removeTableFromEntries(cloneEntries(layout.order), tableName); + const entry: TableVGroupOrderEntry = { type: "table", name: tableName }; + + if (targetGroupId) { + const group = findGroupEntry(order, targetGroupId); + if (group) { + group.children = [...(group.children ?? []), entry]; + return { ...expandGroup(layout, targetGroupId), order }; + } + } + + order.push(entry); + return { ...layout, order }; +} + +export function reorderTableVGroupEntry(layout: TableVGroupLayout, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition): TableVGroupLayout { + if (!hasTableVGroupEntries(layout) || draggedEntryId === targetEntryId) return layout; + + const entryId = (entry: TableVGroupOrderEntry): string => (entry.type === "group" ? entry.id : entry.name); + const removeEntry = (entries: TableVGroupOrderEntry[]): TableVGroupOrderEntry | null => { + for (let i = 0; i < entries.length; i++) { + if (entryId(entries[i]!) === draggedEntryId) return entries.splice(i, 1)[0]!; + const entry = entries[i]!; + if (entry.type === "group") { + const removed = removeEntry(entryChildren(entry)); + if (removed) return removed; + } + } + return null; + }; + + const order = cloneEntries(layout.order); + const dragged = removeEntry(order); + if (!dragged) return layout; + + if (dragged.type === "group" && containsGroup(dragged, targetEntryId)) return layout; + + if (position === "inside") { + const targetGroup = findGroupEntry(order, targetEntryId); + if (targetGroup) { + targetGroup.children = [...(targetGroup.children ?? []), dragged]; + return { ...layout, order }; + } + } + + const insertNear = (entries: TableVGroupOrderEntry[]): boolean => { + for (let i = 0; i < entries.length; i++) { + if (entryId(entries[i]!) === targetEntryId) { + entries.splice(position === "after" ? i + 1 : i, 0, dragged); + return true; + } + const entry = entries[i]!; + if (entry.type === "group" && insertNear(entryChildren(entry))) return true; + } + return false; + }; + + if (!insertNear(order)) order.push(dragged); + return { ...layout, order }; +} + +export function createTableVGroup(layout: TableVGroupLayout, name: string, parentGroupId?: string | null): { layout: TableVGroupLayout; groupId: string } { + const groupId = uuid(); + const group: ConnectionGroup = { id: groupId, name, collapsed: false }; + const order = cloneEntries(layout.order); + const entry: TableVGroupOrderEntry = { type: "group", id: groupId, children: [] }; + + let parentFound = false; + if (parentGroupId) { + const parent = findGroupEntry(order, parentGroupId); + if (parent) { + parent.children = [...(parent.children ?? []), entry]; + parentFound = true; + } else { + order.push(entry); + } + } else { + order.push(entry); + } + + return { + groupId, + layout: { + enabled: layout.enabled, + version: layout.version, + groups: [...layout.groups, group].map((current) => (parentFound && current.id === parentGroupId ? { ...current, collapsed: false } : current)), + order, + }, + }; +} + +export function renameTableVGroup(layout: TableVGroupLayout, groupId: string, name: string): TableVGroupLayout { + return { + ...layout, + groups: layout.groups.map((group) => (group.id === groupId ? { ...group, name } : group)), + }; +} + +/** Deleting groups keeps their tables: members flatten back into the parent level. */ +export function deleteTableVGroups(layout: TableVGroupLayout, groupIds: Iterable): TableVGroupLayout { + if (!hasTableVGroupEntries(layout)) return layout; + const targets = new Set(groupIds); + if (!targets.size) return layout; + + const removedGroupIds = new Set(); + const flattenDeletedGroup = (entry: TableVGroupEntry): TableVGroupOrderEntry[] => { + removedGroupIds.add(entry.id); + return entryChildren(entry).flatMap((child): TableVGroupOrderEntry[] => { + if (child.type === "table") return [{ ...child }]; + return flattenDeletedGroup(child); + }); + }; + const removeGroups = (entries: TableVGroupOrderEntry[]): TableVGroupOrderEntry[] => + entries.flatMap((entry): TableVGroupOrderEntry[] => { + if (entry.type === "table") return [{ ...entry }]; + if (targets.has(entry.id)) return flattenDeletedGroup(entry); + const children = removeGroups(entryChildren(entry)); + return [{ type: "group", id: entry.id, children }]; + }); + + const order = removeGroups(layout.order); + if (!removedGroupIds.size) return layout; + return { enabled: layout.enabled, version: layout.version, groups: layout.groups.filter((group) => !removedGroupIds.has(group.id)), order }; +} + +export function toggleTableVGroupCollapsed(layout: TableVGroupLayout, groupId: string): TableVGroupLayout { + return { + ...layout, + groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: !group.collapsed } : group)), + }; +} + +export function setTableVGroupsEnabled(layout: TableVGroupLayout, enabled: boolean): TableVGroupLayout { + return { ...layout, enabled }; +} + +export interface TableVGroupDestinationRow { + id: string; + name: string; + path: string[]; +} + +/** Build selectable vgroup destinations in the same tree order as the sidebar. */ +export function tableVGroupDestinationRows(layout: TableVGroupLayout | null | undefined): TableVGroupDestinationRow[] { + if (!hasTableVGroupEntries(layout)) return []; + const rows: TableVGroupDestinationRow[] = []; + const walk = (entries: TableVGroupOrderEntry[], path: string[]) => { + for (const entry of entries) { + if (entry.type !== "group") continue; + const name = layout.groups.find((group) => group.id === entry.id)?.name ?? entry.id; + const nextPath = [...path, name]; + rows.push({ id: entry.id, name, path: nextPath }); + walk(entryChildren(entry), nextPath); + } + }; + walk(layout.order, []); + return rows; +} + +/** Stable root-to-leaf group-id path containing the table, empty when ungrouped. */ +export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undefined, tableName: 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 (entry.type !== "group") continue; + const found = walk(entryChildren(entry), [...path, entry.id]); + if (found) return found; + } + return null; + }; + return walk(layout.order, []) ?? []; +} + +// --------------------------------------------------------------------------- +// Display projection +// --------------------------------------------------------------------------- + +interface TableVGroupFlatTable { + node: TreeNode; + flatIndex: number; +} + +function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map, 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); + continue; + } + const group = layout.groups.find((candidate) => candidate.id === entry.id); + if (!group) continue; + nodes.push({ + id: tableVGroupNodeId(entry.id), + label: group.name, + type: "table-vgroup", + connectionId: scope.connectionId, + catalog: scope.catalog, + database: scope.database, + schema: scope.schema, + linkedServer: scope.linkedServer, + vgroupId: entry.id, + isExpanded: !group.collapsed, + children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope), + }); + } + return nodes; +} + +/** + * 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. + */ +function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map, 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 }); + continue; + } + const group = layout.groups.find((candidate) => candidate.id === entry.id); + if (!group) continue; + out.push({ + node: { + id: tableVGroupNodeId(entry.id), + label: group.name, + type: "table-vgroup", + connectionId: scope.connectionId, + catalog: scope.catalog, + database: scope.database, + schema: scope.schema, + linkedServer: scope.linkedServer, + vgroupId: entry.id, + isExpanded: !group.collapsed, + children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope), + }, + // -1e6 keeps every group above flat rows while preserving layout order among groups. + sortIndex: -1_000_000 + groupSeq++, + }); + } + return out; +} + +/** + * 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. + */ +export function applyTableVGroupsToChildren(children: TreeNode[], layout: TableVGroupLayout | null | undefined, scope: TableVGroupScope): TreeNode[] { + if (!children.length || !layout || !tableVGroupsEnabled(layout)) return children; + // Idempotence: merge/refresh flows re-feed already-projected children back + // into setChildren, so always strip existing group containers first — + // otherwise stale group copies survive alongside the freshly built ones. + const activeLayout = layout; + const flatChildren = stripTableVGroupsFromChildren(children); + + const tables = 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 }); + } + if (!tables.size) return flatChildren; + + const rootBuilt = buildVGroupRootNodes(activeLayout.order, activeLayout, tables, scope); + // Tables 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); + 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 }); + } + + const merged = [...rootBuilt, ...passthrough]; + merged.sort((a, b) => a.sortIndex - b.sortIndex); + return merged.map((item) => item.node); +} + +/** + * Inverse of the projection: flatten vgroup containers back into a plain + * table list so a scope can be re-projected from a mutated layout without a + * metadata reload. Table node objects keep their identity. + */ +export function stripTableVGroupsFromChildren(children: TreeNode[]): TreeNode[] { + if (!children.some((node) => node.type === "table-vgroup")) return children; + const result: TreeNode[] = []; + const walk = (nodes: TreeNode[]) => { + for (const node of nodes) { + if (node.type === "table-vgroup") { + walk(node.children ?? []); + continue; + } + result.push(node); + } + }; + walk(children); + return result; +} + +/** + * 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. + */ +export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGroupScope, tableName?: string): TreeNode | null { + const matchesOptional = (nodeValue: string | undefined, scopeValue: string | undefined) => nodeValue === scopeValue || !nodeValue || !scopeValue; + 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)) { + candidates.push(node); + } + if (node.children) walk(node.children); + } + }; + 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 (holder) return holder; + } + return candidates.find((node) => (node.children ?? []).some((child) => child.type === "table" || child.type === "table-vgroup")) ?? candidates[0]!; +} + +/** + * Validate and repair a layout loaded from persistence: malformed JSON + * structures, unknown group references, and duplicate ids are dropped instead + * of poisoning the sidebar. Normalized layouts carry the current version. + */ +export function normalizeTableVGroupLayout(value: unknown): TableVGroupLayout { + const layout = emptyTableVGroupLayout(); + if (!value || typeof value !== "object") return layout; + const raw = value as Partial; + if (!Array.isArray(raw.groups) || !Array.isArray(raw.order)) return layout; + + const seenGroupIds = new Set(); + const groups: ConnectionGroup[] = []; + for (const group of raw.groups) { + if (!group || typeof group !== "object") continue; + const candidate = group as Partial; + if (typeof candidate.id !== "string" || !candidate.id || typeof candidate.name !== "string" || !candidate.name) continue; + if (seenGroupIds.has(candidate.id)) continue; + seenGroupIds.add(candidate.id); + groups.push({ id: candidate.id, name: candidate.name, collapsed: candidate.collapsed === true }); + } + + const validGroupIds = new Set(groups.map((group) => group.id)); + const normalizeEntries = (entries: unknown): TableVGroupOrderEntry[] => { + const out: TableVGroupOrderEntry[] = []; + if (!Array.isArray(entries)) return out; + // 同一层里重复的表名或分组引用会投影出重复节点(渲染以 id 作 key), + // 损坏的持久化数据必须在归一化这一步收敛。 + 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 }); + } 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)) }); + } + } + return out; + }; + const order = normalizeEntries(raw.order); + + return { + version: TABLE_VGROUP_LAYOUT_VERSION, + groups, + order, + enabled: raw.enabled !== false, + }; +} + +function isTableVGroupDisplayNode(type: TreeNode["type"] | undefined): boolean { + return type === "table-vgroup" || (typeof type === "string" && type.startsWith("group-")); +} + +/** + * 对象分组节点(group-tables / group-views…)与投影出的分组行都是宿主容器下的 + * 显示层节点,其 database/schema 字段按查询语义填充(sqlite 把 main 同时填进 + * schema),直接取用会让不同显示模式与不同入口解析出不同 scope_key、把同一批表 + * 的分组劈成两份。因此分组身份一律上溯到 database/schema 一类宿主容器。 + */ +function findTableVGroupHostContainer(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 (isTableVGroupContainerNode(ancestor) && !isTableVGroupDisplayNode(ancestor.type)) 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. + */ +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); + return tableVGroupScopeFieldsOf(container ? tableVGroupScopeHostOf(nodes, container) : (row as TreeNode)); +} + +/** + * 任意深度出现分页游标都说明列表不完整:TDengine 等把末页游标嵌在 STABLE 的子表 + * 分区节点下,只看顶层会把部分页当成全量,进而误删分组成员。 + */ +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 { + 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 (node.children) walk(node.children); + } + }; + walk(nodes); + return names; +} + +/** + * Drop layout members whose names are absent from a complete, unfiltered table + * load (renamed or deleted tables). Callers must only pass complete lists — + * paginated or filtered pages cannot decide membership. Groups themselves are + * kept even when they end up empty: they are user-created structure, so an + * empty group stays visible instead of vanishing with its last table. + */ +export function pruneTableVGroupMembers(layout: TableVGroupLayout, keepNames: ReadonlySet): TableVGroupLayout { + if (!hasTableVGroupEntries(layout)) return layout; + let changed = false; + const prune = (entries: TableVGroupOrderEntry[]): TableVGroupOrderEntry[] => { + const out: TableVGroupOrderEntry[] = []; + for (const entry of entries) { + if (entry.type === "table") { + if (keepNames.has(entry.name)) out.push(entry); + else changed = true; + continue; + } + const children = prune(entryChildren(entry)); + if (children.length !== entryChildren(entry).length) changed = true; + out.push({ type: "group", id: entry.id, children }); + } + return out; + }; + const order = prune(cloneEntries(layout.order)); + return changed ? { enabled: layout.enabled, version: layout.version, groups: layout.groups, order } : layout; +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.oneTimeCleanup.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.oneTimeCleanup.spec.ts index c8e3e1b23a..3e3c7e5ab8 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.oneTimeCleanup.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.oneTimeCleanup.spec.ts @@ -27,6 +27,9 @@ function installApiMocks() { saveEditorSettings: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), + saveTableVGroups: vi.fn().mockResolvedValue(undefined), + deleteTableVGroupsForConnection: vi.fn().mockResolvedValue(undefined), connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined), listInstalledAgents: vi.fn().mockResolvedValue([]), sessionCredentialStatus: vi.fn().mockResolvedValue(false), diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index 4a2ad6b1b0..11a4164b70 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -230,6 +230,7 @@ describe("connectionStore timeout recovery", () => { loadConnections, loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings, @@ -276,6 +277,7 @@ describe("connectionStore timeout recovery", () => { .mockResolvedValue([postgresConnection({ id: "default", connect_timeout_secs: 10, query_timeout_secs: 30 }), postgresConnection({ id: "custom", connect_timeout_secs: 45, query_timeout_secs: 300 }), postgresConnection({ id: "inherited", connect_timeout_secs: 60, query_timeout_secs: 60 })]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections, saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -318,6 +320,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "local", connect_timeout_secs: 10, query_timeout_secs: 30 })]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -353,6 +356,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 7, query_timeout_secs: 12 })]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -384,6 +388,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 20, query_timeout_secs: 45 })]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -426,6 +431,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 99, connect_timeout_inherit: true, query_timeout_secs: 99, query_timeout_inherit: true })]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -462,6 +468,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection()]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), @@ -488,6 +495,7 @@ describe("connectionStore timeout recovery", () => { loadConnections: vi.fn().mockResolvedValue([postgresConnection()]), loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]), loadSidebarLayout: vi.fn().mockResolvedValue(null), + loadTableVGroups: vi.fn().mockResolvedValue({}), loadTunnelProfiles: vi.fn().mockResolvedValue([]), saveConnections: vi.fn().mockResolvedValue(undefined), saveEditorSettings: vi.fn().mockResolvedValue(undefined), diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index aa8053f483..d958111f95 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -66,6 +66,30 @@ import { type DropPosition, type ReorderEntriesOptions, } from "@/lib/sidebar/sidebarLayout"; +import { + applyTableVGroupsToChildren, + collectTableTreeNames, + createTableVGroup as createTableVGroupOp, + deleteTableVGroups as deleteTableVGroupsOp, + emptyTableVGroupLayout, + findTableVGroupContainerNode, + hasTableTreeLoadMore, + hasTableVGroupEntries, + moveTableToVGroup as moveTableToVGroupOp, + normalizeTableVGroupLayout as normalizeTableVGroupLayoutOp, + pruneTableVGroupMembers as pruneTableVGroupMembersOp, + reorderTableVGroupEntry as reorderTableVGroupEntryOp, + renameTableVGroup as renameTableVGroupOp, + resolveTableVGroupScopeFromNode, + setTableVGroupsEnabled as setTableVGroupsEnabledOp, + stripTableVGroupsFromChildren, + tableVGroupPathForTable as tableVGroupPathForTableOp, + tableVGroupScopeKey, + toggleTableVGroupCollapsed as toggleTableVGroupCollapsedOp, + type TableVGroupDropPosition, + type TableVGroupLayout, + type TableVGroupScope, +} from "@/lib/table/tableVGroup"; import { buildConnectionConfigBundle, filterSidebarLayoutByConnectionIds, @@ -601,6 +625,9 @@ export const useConnectionStore = defineStore("connection", () => { allDatabases?: boolean; } | null>(null); const sidebarLayout = ref(emptyLayout()); + const tableVGroupLayouts = ref>({}); + const dirtyTableVGroupScopeKeys = new Set(); + let tableVGroupPersistTimer: ReturnType | null = null; const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value)); const connectionGroupOptions = computed(() => connectionGroupDestinationRows(sidebarLayout.value)); const selectedConnectionGroupId = computed(() => { @@ -1789,6 +1816,9 @@ export const useConnectionStore = defineStore("connection", () => { } children = children.map((child) => { const old = oldMap.get(child.id); + // Virtual group containers are rebuilt from the layout on every projection; + // stale copies must never override the freshly computed arrangement. + if (child.type === "table-vgroup") return child; if (old?.isLoading) { const isExpanded = old.isExpanded; const isLoading = old.isLoading; @@ -1823,6 +1853,7 @@ export const useConnectionStore = defineStore("connection", () => { persistPinnedTreeNodeIds(); } syncPinnedTreeState(children); + children = applyTableVGroupsToChildren(children, tableVGroupLayoutForNode(parent), parent); parent.children = markRawLeafTreeNodes(children); loadedTreeNodeChildrenIds.value.add(parent.id); syncConfirmedEmptyTreeNodeId(parent); @@ -2738,7 +2769,9 @@ export const useConnectionStore = defineStore("connection", () => { } async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) { - await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripTreeNodeExpansionState(stripDatabaseSavedSqlTreeNodes(children)))).catch(() => undefined); + // 分组是显示层投影,绝不写入元数据缓存——否则重启后会随缓存复现。 + const cacheChildren = stripTableVGroupsFromChildren(children); + await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripTreeNodeExpansionState(stripDatabaseSavedSqlTreeNodes(cacheChildren)))).catch(() => undefined); } function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null { @@ -3561,9 +3594,26 @@ export const useConnectionStore = defineStore("connection", () => { for (const id of removedIds) nextLayout = removeConnectionFromSidebarLayout(nextLayout, id); await persistConnectionDeletion(nextConnections, nextLayout); applyConnectionRemoval(removedIds, nextConnections, nextLayout); + purgeTableVGroupsForConnections(removedIds); await cleanupRemovedOneTimeConnections(oneTimeIds); } + /** 删除连接后同步清理其名下的表分组布局(本地内存 + dbx.db 行)。 */ + function purgeTableVGroupsForConnections(removedIds: ReadonlySet) { + const nextLayouts = { ...tableVGroupLayouts.value }; + let removed = false; + for (const id of removedIds) { + const prefix = `${id}\u0000`; + for (const key of Object.keys(nextLayouts)) { + if (key.startsWith(prefix)) { + delete nextLayouts[key]; + removed = true; + } + } + } + if (removed) tableVGroupLayouts.value = nextLayouts; + for (const id of removedIds) void api.deleteTableVGroupsForConnection(id).catch(() => {}); + } function applyConnectionRemoval(removedIds: ReadonlySet, nextConnections: ConnectionConfig[], nextLayout: SidebarLayout) { connections.value = nextConnections; syncTimeoutInheritanceBackup(); @@ -5972,6 +6022,8 @@ export const useConnectionStore = defineStore("connection", () => { setChildren(targetNode, children); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); + // grouped 显示下这里加载的是分组占位节点;xugu 协议命名空间的空列表也不代表表被删。 + pruneTableVGroupStaleMembers(targetNode, children, simpleObjectDisplay && !isPublicSynonymScope && !isSchedulerJobScope); } const currentTargetNode = treeNodeLoadTarget(load); if (!currentTargetNode) return; @@ -6128,6 +6180,7 @@ export const useConnectionStore = defineStore("connection", () => { options?.onChildrenApplied?.(targetNode); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); + pruneTableVGroupStaleMembers(targetNode, children, targetNode.type === "group-tables"); } const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; @@ -6237,6 +6290,8 @@ export const useConnectionStore = defineStore("connection", () => { if (!options?.searchFilter) { await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren); } + // 该分支只服务 simple 库/模式表列表;搜索分页结果不是全量,不能作为成员依据。 + if (!page.hasMore && !options?.searchFilter) pruneTableVGroupStaleMembers(targetParent, nextChildren, true); const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; return; @@ -6303,6 +6358,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"); } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; @@ -8765,6 +8823,72 @@ export const useConnectionStore = defineStore("connection", () => { }, 300); } + /** 表分组的 scope 一律从「解析到的容器」派生:simple 显示模式下表行带 schema + * 而容器不带,直接用行字段算 key 会把读写劈成两份。入参可以是树行,也可以是 + * 只带身份字段的纯 scope 值(action 参数),后者按字段匹配容器。 */ + function resolveTableVGroupScope(node: TreeNode | TableVGroupScope): { scope: TableVGroupScope; scopeKey: string } | null { + const scope = resolveTableVGroupScopeFromNode(treeNodes.value, node); + const scopeKey = tableVGroupScopeKey(scope); + return scopeKey ? { scope, scopeKey } : null; + } + + function tableVGroupLayoutForNode(node: TreeNode): TableVGroupLayout | undefined { + const resolved = resolveTableVGroupScope(node); + return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined; + } + + function scheduleTableVGroupPersistFlush() { + if (tableVGroupPersistTimer) clearTimeout(tableVGroupPersistTimer); + tableVGroupPersistTimer = setTimeout(() => { + tableVGroupPersistTimer = null; + for (const key of dirtyTableVGroupScopeKeys) { + const layout = tableVGroupLayouts.value[key]; + if (layout) api.saveTableVGroups(key, layout).catch(() => {}); + } + dirtyTableVGroupScopeKeys.clear(); + }, 300); + } + + /** 分组节点是显示层投影:按已解析的作用域重建容器子节点,容器里不留投影副本。 */ + function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string) { + const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName); + if (!container?.children) return; + container.children = applyTableVGroupsToChildren(stripTableVGroupsFromChildren(container.children), tableVGroupLayouts.value[scopeKey], scope); + } + + /** 表分组成员回收:只有「本 scope 的完整表列表」才能判定成员存亡。 + * 同一 scope_key 下并存多种列表:grouped 的各对象分组(视图/函数…只含非表行)、 + * simple 的库/模式表列表、以及分页中间态。非表列表或分页结果作为依据会把有效 + * 成员误删并持久化,故列表语义由调用点声明(`completeTableList`),本函数只复核分页态。 */ + function pruneTableVGroupStaleMembers(parent: TreeNode, children: TreeNode[], completeTableList: boolean) { + if (!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 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) { + tableVGroupLayouts.value = { ...tableVGroupLayouts.value, [scopeKey]: nextLayout }; + dirtyTableVGroupScopeKeys.add(scopeKey); + scheduleTableVGroupPersistFlush(); + reprojectTableVGroupScope(scope, scopeKey, tableName); + } + + /** 分组变更的统一入口:读当前布局 → 变换 → 写回。作用域不可解析或尚无布局时跳过。 */ + function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: 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); + } + function rebuildTreeNodes() { const existingNodesMap = new Map(); const collectExisting = (nodes: TreeNode[]) => { @@ -8828,6 +8952,7 @@ export const useConnectionStore = defineStore("connection", () => { await persistConnectionDeletion(nextConnections, nextLayout); if (removedConnectionIds.size) { applyConnectionRemoval(removedConnectionIds, nextConnections, nextLayout); + purgeTableVGroupsForConnections(removedConnectionIds); } else { sidebarLayout.value = nextLayout; rebuildTreeNodes(); @@ -9289,7 +9414,7 @@ export const useConnectionStore = defineStore("connection", () => { await settingsStore.initEditorSettings(); if (!initFromDiskPromise) { initFromDiskPromise = (async () => { - const [pinnedOrder, saved] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init()]); + const [pinnedOrder, saved, , loadedTableVGroups] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init(), api.loadTableVGroups()]); setPinnedTreeNodeOrder(pinnedOrder); await migrateTimeoutInheritance(saved); const loadedConnections = saved.map(normalizeConnection); @@ -9303,6 +9428,8 @@ export const useConnectionStore = defineStore("connection", () => { await persistConnections(); } syncTimeoutInheritanceBackup(); + // 旧版本写下的行可能缺字段(如 version),一律归一化后再进内存,避免脏数据被原样写回。 + tableVGroupLayouts.value = Object.fromEntries(Object.entries(loadedTableVGroups ?? {}).map(([scopeKey, layout]) => [scopeKey, normalizeTableVGroupLayoutOp(layout)])); const savedLayout = await api.loadSidebarLayout(); const currentLayout = sidebarLayout.value.groups.length || sidebarLayout.value.order.length ? sidebarLayout.value : null; sidebarLayout.value = reconcileLayout( @@ -9584,5 +9711,40 @@ export const useConnectionStore = defineStore("connection", () => { const layout = reorderEntriesOp(sidebarLayout.value, draggedIds, targetId, position, options); if (layout !== sidebarLayout.value) updateLayoutAndRebuild(layout); }, + tableVGroupLayouts, + resolveTableVGroupScope, + tableVGroupLayoutFor(scope: TableVGroupScope) { + const resolved = resolveTableVGroupScope(scope); + return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined; + }, + createTableVGroup(scope: TableVGroupScope, name: string, parentGroupId?: string | null) { + const resolved = resolveTableVGroupScope(scope); + if (!resolved) return null; + const result = createTableVGroupOp(tableVGroupLayouts.value[resolved.scopeKey] ?? emptyTableVGroupLayout(), name, parentGroupId); + updateTableVGroupLayout(resolved.scope, resolved.scopeKey, result.layout); + return result.groupId; + }, + renameTableVGroup(scope: TableVGroupScope, groupId: string, name: string) { + updateTableVGroupLayoutFor(scope, (layout) => renameTableVGroupOp(layout, groupId, name)); + }, + 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); + }, + reorderTableVGroupEntry(scope: TableVGroupScope, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition) { + updateTableVGroupLayoutFor(scope, (layout) => reorderTableVGroupEntryOp(layout, draggedEntryId, targetEntryId, position)); + }, + toggleTableVGroupCollapsed(scope: TableVGroupScope, groupId: string) { + updateTableVGroupLayoutFor(scope, (layout) => toggleTableVGroupCollapsedOp(layout, groupId)); + }, + setTableVGroupsEnabled(scope: TableVGroupScope, enabled: boolean) { + updateTableVGroupLayoutFor(scope, (layout) => setTableVGroupsEnabledOp(layout, enabled)); + }, + tableVGroupPathForTable(scope: TableVGroupScope, tableName: string) { + const resolved = resolveTableVGroupScope(scope); + return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName); + }, }; }); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index a2103ecca0..701f5ba61f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1339,6 +1339,7 @@ export type TreeNodeType = | "group-table-partitions" | "group-table-subpartitions" | "group-tables" + | "table-vgroup" | "group-dolt-system-tables" | "group-views" | "group-materialized-views" @@ -1413,6 +1414,16 @@ export interface SidebarLayout { order: SidebarOrderEntry[]; } +export type TableVGroupOrderEntry = { type: "group"; id: string; children?: TableVGroupOrderEntry[] } | { type: "table"; name: string }; + +export interface TableVGroupLayout { + version?: number; + groups: ConnectionGroup[]; + order: TableVGroupOrderEntry[]; + /** Toggled by the container context menu to hide groups without deleting them. */ + enabled?: boolean; +} + export interface TreeNode { id: string; label: string; @@ -1468,6 +1479,8 @@ export interface TreeNode { tableSearchParentId?: string; savedSqlId?: string; savedSqlFolderId?: string; + /** Set on synthetic table virtual-group container nodes. */ + vgroupId?: string; meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta | CustomTypeTreeMemberMeta; loadMore?: { parentId: string; diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index d02c07af62..8888b50d14 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -775,6 +775,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id INTEGER PRIMARY KEY CHECK (id = 1), layout_json TEXT NOT NULL )", + "CREATE TABLE IF NOT EXISTS table_vgroups ( + scope_key TEXT PRIMARY KEY, + layout_json TEXT NOT NULL + )", "CREATE TABLE IF NOT EXISTS app_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), settings_json TEXT NOT NULL @@ -4475,6 +4479,55 @@ impl Storage { .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } + + pub async fn save_table_vgroups(&self, scope_key: &str, layout: &serde_json::Value) -> Result<(), String> { + let scope_key = scope_key.to_string(); + let json = serde_json::to_string(layout).map_err(|e| e.to_string())?; + self.with_conn(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO table_vgroups (scope_key, layout_json) VALUES (?1, ?2)", + params![scope_key, json], + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn load_table_vgroups(&self) -> Result { + self.with_conn(|conn| { + let mut stmt = + conn.prepare("SELECT scope_key, layout_json FROM table_vgroups").map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + let mut layouts = serde_json::Map::new(); + for row in rows { + let (scope_key, json) = row.map_err(|e| e.to_string())?; + match serde_json::from_str::(&json) { + Ok(layout) => { + layouts.insert(scope_key, layout); + } + Err(e) => warn!("Failed to deserialize table vgroups for scope {scope_key}: {e}"), + } + } + Ok(serde_json::Value::Object(layouts)) + }) + .await + } + + /// 删除连接时清理其名下全部表分组布局(scope_key 前缀 = `{connectionId}\u{0}`)。 + pub async fn delete_table_vgroups_for_connection(&self, connection_id: &str) -> Result<(), String> { + // 前缀用 instr 做大小写敏感的字节匹配:LIKE 默认大小写不敏感且把 `%`/`_` 当通配符, + // 会连带删除 id 仅大小写不同或含通配符的连接行。 + let prefix = format!("{connection_id}\u{0}"); + self.with_conn(move |conn| { + conn.execute("DELETE FROM table_vgroups WHERE instr(scope_key, ?1) = 1", params![prefix]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + } } // Schema cache @@ -5434,6 +5487,38 @@ mod tests { dir } + #[tokio::test] + async fn delete_table_vgroups_for_connection_scopes_by_prefix() { + let path = temp_db_path("vgroup-delete"); + let storage = Storage::open(&path).await.unwrap(); + + let layout = serde_json::json!({ "version": 1, "groups": [], "order": [] }); + // 真实 scope_key 形如 `{connectionId}\u{0}{linkedServer}\u{0}{catalog}\u{0}{database}\u{0}{schema}`。 + storage.save_table_vgroups("conn-1\u{0}\u{0}\u{0}db1\u{0}", &layout).await.unwrap(); + storage.save_table_vgroups("conn-1\u{0}\u{0}\u{0}db2\u{0}", &layout).await.unwrap(); + storage.save_table_vgroups("conn-2\u{0}\u{0}\u{0}db1\u{0}", &layout).await.unwrap(); + // 前缀恰好是另一连接 id 的连接不能被误删。 + storage.save_table_vgroups("conn-12\u{0}\u{0}\u{0}db1\u{0}", &layout).await.unwrap(); + // 仅大小写不同、或 id 含 LIKE 通配符的连接同样不能被误删。 + storage.save_table_vgroups("CONN-1\u{0}\u{0}\u{0}db1\u{0}", &layout).await.unwrap(); + storage.save_table_vgroups("conn_1\u{0}\u{0}\u{0}db1\u{0}", &layout).await.unwrap(); + + storage.delete_table_vgroups_for_connection("conn-1").await.unwrap(); + + let remaining = storage.load_table_vgroups().await.unwrap(); + let mut keys: Vec<&String> = remaining.as_object().unwrap().keys().collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "CONN-1\u{0}\u{0}\u{0}db1\u{0}", + "conn-12\u{0}\u{0}\u{0}db1\u{0}", + "conn-2\u{0}\u{0}\u{0}db1\u{0}", + "conn_1\u{0}\u{0}\u{0}db1\u{0}", + ] + ); + } + #[cfg(unix)] fn file_mode(path: &std::path::Path) -> Option { use std::os::unix::fs::PermissionsExt; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 7902802868..7c744a0b2a 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -1112,6 +1112,14 @@ async fn main() { .route("/changelog", get(routes::update::fetch_changelog)) // Layout .route("/layout/sidebar", post(routes::layout::save_sidebar_layout).get(routes::layout::load_sidebar_layout)) + .route( + "/layout/table-vgroups", + post(routes::layout::save_table_vgroups).get(routes::layout::load_table_vgroups), + ) + .route( + "/layout/table-vgroups/connection/{connection_id}", + delete(routes::layout::delete_table_vgroups_for_connection), + ) // App settings .route( "/app-settings/pinned-tree-node-ids", diff --git a/crates/dbx-web/src/routes/layout.rs b/crates/dbx-web/src/routes/layout.rs index 1a7c54e301..3ad241a41d 100644 --- a/crates/dbx-web/src/routes/layout.rs +++ b/crates/dbx-web/src/routes/layout.rs @@ -25,3 +25,31 @@ pub async fn load_sidebar_layout(State(state): State>) -> Result>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_table_vgroups(&body.scope_key, &body.layout).await.map_err(AppError::from)?; + Ok(Json(())) +} + +pub async fn load_table_vgroups(State(state): State>) -> Result, AppError> { + let layouts = state.app.storage.load_table_vgroups().await.map_err(AppError::from)?; + Ok(Json(layouts)) +} + +pub async fn delete_table_vgroups_for_connection( + State(state): State>, + axum::extract::Path(connection_id): axum::extract::Path, +) -> Result, AppError> { + state.app.storage.delete_table_vgroups_for_connection(&connection_id).await.map_err(AppError::from)?; + Ok(Json(())) +} diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 087c332c4a..7f00044e92 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1035,6 +1035,28 @@ pub async fn load_sidebar_layout(state: State<'_, Arc>) -> Result>, + scope_key: String, + layout: serde_json::Value, +) -> Result<(), String> { + state.storage.save_table_vgroups(&scope_key, &layout).await +} + +#[tauri::command] +pub async fn load_table_vgroups(state: State<'_, Arc>) -> Result { + state.storage.load_table_vgroups().await +} + +#[tauri::command] +pub async fn delete_table_vgroups_for_connection( + state: State<'_, Arc>, + connection_id: String, +) -> Result<(), String> { + state.storage.delete_table_vgroups_for_connection(&connection_id).await +} + fn sqlite_extension_specs_from_config(config: &ConnectionConfig) -> Vec { db::sqlite::sqlite_extension_specs_from_url_params(config.url_params.as_deref()) .into_iter() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 474c8334fc..d777e59d33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1870,6 +1870,9 @@ pub fn run() { commands::connection::load_connections, commands::connection::save_sidebar_layout, commands::connection::load_sidebar_layout, + commands::connection::save_table_vgroups, + commands::connection::load_table_vgroups, + commands::connection::delete_table_vgroups_for_connection, commands::plugins::list_plugins, commands::plugins::list_plugin_trusted_keys, commands::plugins::save_plugin_trusted_key,