feat(sidebar): add nested virtual table groups with drag and batch move

This commit is contained in:
ReadMe.md authored and GitHub committed 2026-09-19 09:24:34 +08:00
1 parent 0db9756a57
commit 1f43d184aa
31 files changed
+1714 -20

No files matched your search

@@ -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
</template>
</SidebarDangerConfirmDialog>
<SidebarTreeItemDialogs v-if="sidebarTreeItemDialogController" :key="sidebarTreeItemDialogController.node?.id" :controller="sidebarTreeItemDialogController" @closed="sidebarTreeItemDialogController = null" />
<SidebarTableVGroupDialog @created="focusCreatedTableVGroup" />
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" @changed="refreshSidebarActionTarget" />
<ExtensionDetailsDialog v-if="sidebarExtensionDetailsTarget" ref="sidebarExtensionDetailsDialogRef" :node="sidebarExtensionDetailsTarget" />
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { tableVGroupNodeId } from "@/lib/table/tableVGroup";
import { showTableVGroupDialog, tableVGroupName, tableVGroupDialogScope, tableVGroupDialogParentGroupId, tableVGroupDialogTableNames, showTableVGroupDeleteConfirm, tableVGroupDeleteTarget } from "./sidebarTreeDialogState";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const emit = defineEmits<{ created: [groupId: string] }>();
const deleteConfirmMessage = computed(() => t("tableVGroup.deleteGroupConfirmMessage", { name: tableVGroupDeleteTarget.value?.name ?? "" }));
function confirmCreate() {
const scope = tableVGroupDialogScope.value;
const name = tableVGroupName.value.trim();
if (!scope || !name) return;
const groupId = connectionStore.createTableVGroup(scope, name, tableVGroupDialogParentGroupId.value);
if (groupId) {
for (const tableName of tableVGroupDialogTableNames.value) {
connectionStore.moveTableToVGroup(scope, tableName, groupId);
}
emit("created", tableVGroupNodeId(groupId));
}
showTableVGroupDialog.value = false;
tableVGroupName.value = "";
}
function confirmDelete() {
const target = tableVGroupDeleteTarget.value;
showTableVGroupDeleteConfirm.value = false;
tableVGroupDeleteTarget.value = null;
if (!target) return;
connectionStore.deleteTableVGroups(target.scope, [target.groupId]);
toast(t("tableVGroup.groupDeleted"), 2000);
}
</script>
<template>
<Dialog v-model:open="showTableVGroupDialog">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle>{{ tableVGroupDialogTableNames.length ? t("tableVGroup.moveToNewGroup") : t("tableVGroup.newSubgroup") }}</DialogTitle>
</DialogHeader>
<Input v-model="tableVGroupName" :placeholder="t('connectionGroup.groupNamePlaceholder')" @keydown.enter.prevent="confirmCreate" />
<DialogFooter>
<Button variant="outline" @click="showTableVGroupDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!tableVGroupName.trim()" @click="confirmCreate">{{ t("connectionGroup.createGroup") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showTableVGroupDeleteConfirm">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle>{{ t("tableVGroup.deleteGroupConfirmTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ deleteConfirmMessage }}</p>
<DialogFooter>
<Button variant="outline" @click="showTableVGroupDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="confirmDelete">{{ t("tableVGroup.deleteGroup") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -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;
@@ -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<HTMLInputElement>();
/** Snapshot of the row being renamed: reprojecting the tree mid-rename recycles activeNode. */
const renameTargetNode = shallowRef<TreeNode | null>(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"
@@ -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<TreeNode | null>(null);
export const connectionDeleteTargetSnapshot = ref<ConnectionDeleteTarget[]>([]);
export const connectionGroupDeleteTargetSnapshot = ref<ConnectionGroupDeleteTarget[]>([]);
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<TableVGroupScope | null>(null);
export const tableVGroupDialogParentGroupId = ref<string | null>(null);
export const tableVGroupDialogTableNames = ref<string[]>([]);
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 = [];
}
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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: "グループ",
+13
View File
@@ -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: "그룹",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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: "分组",
+13
View File
@@ -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: "群組",
+3
View File
@@ -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)
+13
View File
@@ -44,6 +44,7 @@ import type {
JdbcMavenBundleInfo,
JdbcPluginStatus,
SidebarLayout,
TableVGroupLayout,
SavedSqlFile,
SavedSqlFolder,
SavedSqlLibrary,
@@ -5088,6 +5089,18 @@ export async function loadSidebarLayout(): Promise<SidebarLayout | null> {
return get("/api/layout/sidebar");
}
export async function saveTableVGroups(scopeKey: string, layout: TableVGroupLayout): Promise<void> {
return post("/api/layout/table-vgroups", { scopeKey, layout });
}
export async function loadTableVGroups(): Promise<Record<string, TableVGroupLayout>> {
return get("/api/layout/table-vgroups");
}
export async function deleteTableVGroupsForConnection(connectionId: string): Promise<void> {
return del(`/api/layout/table-vgroups/connection/${encodeURIComponent(connectionId)}`);
}
export async function refreshConnections(): Promise<void> {
// Web mode doesn't maintain persistent connection pools - no-op
}
+13
View File
@@ -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<import("@/types/database").Si
return invoke("load_sidebar_layout");
}
export async function saveTableVGroups(scopeKey: string, layout: TableVGroupLayout): Promise<void> {
return invoke("save_table_vgroups", { scopeKey, layout });
}
export async function loadTableVGroups(): Promise<Record<string, import("@/types/database").TableVGroupLayout>> {
return invoke("load_table_vgroups");
}
export async function deleteTableVGroupsForConnection(connectionId: string): Promise<void> {
return invoke("delete_table_vgroups_for_connection", { connectionId });
}
// --- Updates ---
export interface UpdateInfo {
current_version: string;
@@ -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<TreeNodeType>(["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 };
});
}
@@ -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<string | null>(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;
}
+285
View File
@@ -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<string, unknown> & { 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<typeof reorderTableVGroupEntry>, 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);
}
+608
View File
@@ -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<TableVGroupOrderEntry, { type: "group" }>;
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<TreeNode["type"]> = 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<string>): TableVGroupLayout {
if (!hasTableVGroupEntries(layout)) return layout;
const targets = new Set(groupIds);
if (!targets.size) return layout;
const removedGroupIds = new Set<string>();
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<string, TableVGroupFlatTable>, 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<string, TableVGroupFlatTable>, 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<string, TableVGroupFlatTable>();
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<TreeNode>();
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<TableVGroupLayout>;
if (!Array.isArray(raw.groups) || !Array.isArray(raw.order)) return layout;
const seenGroupIds = new Set<string>();
const groups: ConnectionGroup[] = [];
for (const group of raw.groups) {
if (!group || typeof group !== "object") continue;
const candidate = group as Partial<ConnectionGroup>;
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<string>();
const seenGroupEntries = new Set<string>();
for (const entry of entries) {
if (!entry || typeof entry !== "object") continue;
const candidate = entry as Partial<TableVGroupOrderEntry>;
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<TreeNode> & 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<string> {
const names = new Set<string>();
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<string>): 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;
}
@@ -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),
@@ -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),
+164 -2
View File
@@ -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<SidebarLayout>(emptyLayout());
const tableVGroupLayouts = ref<Record<string, TableVGroupLayout>>({});
const dirtyTableVGroupScopeKeys = new Set<string>();
let tableVGroupPersistTimer: ReturnType<typeof setTimeout> | 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<string>) {
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<string>, 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<string, TreeNode>();
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<string>) {
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);
},
};
});
+13
View File
@@ -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;