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,