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

@@ -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);
},
};
});