feat(sidebar): extend table grouping to all object containers

This commit is contained in:
ReadMe.md authored and GitHub committed 2026-09-22 15:52:04 +08:00
1 parent e1a512fd59
commit 591e8f4909
20 files changed
+607 -169

No files matched your search

@@ -15,10 +15,17 @@ describe("connectionStore table virtual groups during disk reload", () => {
vi.doMock("@/lib/backend/api", () => ({
loadConnections: vi.fn().mockResolvedValue([]),
loadEditorSettings: vi.fn().mockResolvedValue(null),
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listTables: vi.fn().mockResolvedValue([]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
loadTableVGroups: vi.fn().mockResolvedValue({}),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
listObjects: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
savePinnedTreeNodeIds: vi.fn().mockResolvedValue(undefined),
@@ -80,4 +87,55 @@ describe("connectionStore table virtual groups during disk reload", () => {
expect(api.loadConnections).toHaveBeenCalledTimes(4);
expect(store.tableVGroupLayoutFor(container)).toEqual(layout);
});
it("keeps views layouts separate from tables layouts for the same database", async () => {
const api = await import("@/lib/backend/api");
const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup");
const { useConnectionStore } = await import("@/stores/connectionStore");
const config: ConnectionConfig = { id: "pg-9733", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" };
const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const };
const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] };
vi.mocked(api.loadConnections).mockResolvedValue([config]);
vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout });
const store = useConnectionStore();
await store.initFromDisk();
const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app" };
const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews] };
store.treeNodes[0]!.isExpanded = true;
store.treeNodes[0]!.children!.push(database);
expect(store.tableVGroupLayoutFor(groupViews)).toEqual(viewsLayout);
expect(store.tableVGroupLayoutFor(database)).toBeUndefined();
});
it("keeps projected view groups through the setChildren reload path", async () => {
const api = await import("@/lib/backend/api");
const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup");
const { useConnectionStore } = await import("@/stores/connectionStore");
const config: ConnectionConfig = { id: "pg-view-1", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" };
const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const };
const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] };
vi.mocked(api.loadConnections).mockResolvedValue([config]);
vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout });
const store = useConnectionStore();
await store.initFromDisk();
const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app", children: [], isExpanded: true };
const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews], isExpanded: true };
vi.mocked(api.listTables).mockResolvedValue([
{ name: "v_pay", table_type: "VIEW", schema: "public", comment: null },
{ name: "v_other", table_type: "VIEW", schema: "public", comment: null },
]);
store.connectedIds = new Set([config.id]);
store.treeNodes[0]!.isExpanded = true;
store.treeNodes[0]!.children!.push(database);
await store.refreshTreeNode(groupViews);
// setChildren 必须用解析后的 views scope 投影:分组顶置、成员重挂、非成员原位
const labels = groupViews.children?.map((node) => node.label) ?? [];
expect(labels[0]).toBe("支付视图");
expect(groupViews.children![0]!.type).toBe("table-vgroup");
expect(groupViews.children![0]!.children?.map((node) => node.label)).toEqual(["v_pay"]);
expect(labels).toContain("v_other");
});
});
+47 -24
View File
@@ -84,6 +84,7 @@ import {
resolveTableVGroupScopeFromNode,
setTableVGroupsEnabled as setTableVGroupsEnabledOp,
stripTableVGroupsFromChildren,
tableVGroupKindOfContainerNode,
tableVGroupPathForTable as tableVGroupPathForTableOp,
tableVGroupScopeKey,
toggleTableVGroupCollapsed as toggleTableVGroupCollapsedOp,
@@ -1949,7 +1950,8 @@ export const useConnectionStore = defineStore("connection", () => {
persistPinnedTreeNodeIds();
}
syncPinnedTreeState(children);
children = applyTableVGroupsToChildren(children, tableVGroupLayoutForNode(parent), parent);
const vgroupResolved = resolveTableVGroupScope(parent);
children = applyTableVGroupsToChildren(children, vgroupResolved ? tableVGroupLayouts.value[vgroupResolved.scopeKey] : undefined, vgroupResolved?.scope ?? parent);
parent.children = markRawLeafTreeNodes(children);
loadedTreeNodeChildrenIds.value.add(parent.id);
syncConfirmedEmptyTreeNodeId(parent);
@@ -6393,7 +6395,7 @@ export const useConnectionStore = defineStore("connection", () => {
options?.onChildrenApplied?.(targetNode);
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
pruneTableVGroupStaleMembers(targetNode, children, targetNode.type === "group-tables");
pruneTableVGroupStaleMembers(targetNode, children, tableVGroupKindOfContainerNode(targetNode) !== null);
}
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
@@ -6560,6 +6562,9 @@ export const useConnectionStore = defineStore("connection", () => {
setChildren(targetParent, nextChildren);
if (!options?.searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// procedures/triggers 等对象组在此分支提前 return,回收必须在 return 前;
// 非分组容器由 prune 内部的类别防护挡下。
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
}
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
@@ -6571,9 +6576,9 @@ export const useConnectionStore = defineStore("connection", () => {
setChildren(targetParent, nextChildren);
if (!options?.searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// 只认 group-tables:group-views 等分组与表共用 scope_key,其列表不是全量表;
// 分页中间态(含 load-more)由 prune 内部再挡一次。
pruneTableVGroupStaleMembers(targetParent, nextChildren, parent.type === "group-tables");
// 各分组容器按自身类别回收完整列表的失效成员;分页中间态(含
// load-more)由 prune 内部再挡一次。
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
}
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
@@ -9045,9 +9050,22 @@ export const useConnectionStore = defineStore("connection", () => {
return scopeKey ? { scope, scopeKey } : null;
}
function tableVGroupLayoutForNode(node: TreeNode): TableVGroupLayout | undefined {
const resolved = resolveTableVGroupScope(node);
return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
/** 立即落盘全部待写布局(清防抖计时器与脏集合):initFromDisk 等整体覆盖内存布局的
* 路径必须先调用,否则窗口内 ≤300ms 的分组编辑会被旧快照静默回滚。 */
async function flushTableVGroupPersist(): Promise<void> {
if (tableVGroupPersistTimer) {
clearTimeout(tableVGroupPersistTimer);
tableVGroupPersistTimer = null;
}
if (!dirtyTableVGroupScopeKeys.size) return;
const pending = [...dirtyTableVGroupScopeKeys];
dirtyTableVGroupScopeKeys.clear();
await Promise.all(
pending.map((key) => {
const layout = tableVGroupLayouts.value[key];
return layout ? api.saveTableVGroups(key, layout).catch(() => {}) : Promise.resolve();
}),
);
}
function scheduleTableVGroupPersistFlush() {
@@ -9063,43 +9081,45 @@ export const useConnectionStore = defineStore("connection", () => {
}
/** 分组节点是显示层投影:按已解析的作用域重建容器子节点,容器里不留投影副本。 */
function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string) {
const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName);
function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string, rowType?: string) {
const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName, rowType);
if (!container?.children) return;
container.children = applyTableVGroupsToChildren(stripTableVGroupsFromChildren(container.children), tableVGroupLayouts.value[scopeKey], scope);
}
/** 表分组成员回收:只有「本 scope 的完整表列表」才能判定成员存亡。
* 同一 scope_key 下并存多种列表:grouped 的各对象分组(视图/函数…只含非表行)、
* simple 的库/模式表列表、以及分页中间态。非表列表或分页结果作为依据会把有效
* 成员误删并持久化,故列表语义由调用点声明(`completeTableList`),本函数只复核分页态。 */
/** 分组成员回收:只有「本 scope 的完整列表」才能判定成员存亡。
* 同一连接下并存多种列表:各分组容器(tables/views/procedures/triggers 各自的
* 完整列表)、非分组容器列表、以及分页中间态。非本容器类别的列表或分页结果作为
* 依据会把有效成员误删并持久化,故列表语义由调用点声明(`completeTableList`),
* 本函数复核容器类别与分页态,回收范围限定容器自身类别(objectType)。 */
function pruneTableVGroupStaleMembers(parent: TreeNode, children: TreeNode[], completeTableList: boolean) {
if (!completeTableList) return;
const kind = tableVGroupKindOfContainerNode(parent);
if (!kind || !completeTableList) return;
const resolved = resolveTableVGroupScope(parent);
const layout = resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
if (!resolved || !hasTableVGroupEntries(layout)) return;
if (hasTableTreeLoadMore(children)) return;
const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children));
const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children), kind);
const next = pruneTableVGroupMembersOp(layout, keepNames);
if (next === layout) return;
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, next);
}
/** 写入某作用域的布局:即时重投影 + 合并 300ms 后落盘。作用域须已解析出 scopeKey。 */
function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string) {
function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string, rowType?: string) {
tableVGroupLayouts.value = { ...tableVGroupLayouts.value, [scopeKey]: nextLayout };
dirtyTableVGroupScopeKeys.add(scopeKey);
scheduleTableVGroupPersistFlush();
reprojectTableVGroupScope(scope, scopeKey, tableName);
reprojectTableVGroupScope(scope, scopeKey, tableName, rowType);
}
/** 分组变更的统一入口:读当前布局 → 变换 → 写回。作用域不可解析或尚无布局时跳过。 */
function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string) {
function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string, rowType?: string) {
const resolved = resolveTableVGroupScope(scope);
if (!resolved) return;
const current = tableVGroupLayouts.value[resolved.scopeKey];
if (!current) return;
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName);
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName, rowType);
}
function rebuildTreeNodes() {
@@ -9635,6 +9655,9 @@ export const useConnectionStore = defineStore("connection", () => {
await settingsStore.initEditorSettings();
if (!initFromDiskPromise) {
initFromDiskPromise = (async () => {
// 整体覆盖内存布局前,先落盘窗口内未写盘的分组编辑(防止 300ms 防抖窗口
// 内的编辑被备份轮询/重载带回的旧快照静默回滚)。
await flushTableVGroupPersist();
const [pinnedOrder, saved, , loadedTableVGroups] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init(), api.loadTableVGroups()]);
setPinnedTreeNodeOrder(pinnedOrder);
await migrateTimeoutInheritance(saved);
@@ -9953,8 +9976,8 @@ export const useConnectionStore = defineStore("connection", () => {
deleteTableVGroups(scope: TableVGroupScope, groupIds: Iterable<string>) {
updateTableVGroupLayoutFor(scope, (layout) => deleteTableVGroupsOp(layout, groupIds));
},
moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null) {
updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId), tableName);
moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null, rowType?: string) {
updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId, rowType), tableName, rowType);
},
reorderTableVGroupEntry(scope: TableVGroupScope, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition) {
updateTableVGroupLayoutFor(scope, (layout) => reorderTableVGroupEntryOp(layout, draggedEntryId, targetEntryId, position));
@@ -9965,9 +9988,9 @@ export const useConnectionStore = defineStore("connection", () => {
setTableVGroupsEnabled(scope: TableVGroupScope, enabled: boolean) {
updateTableVGroupLayoutFor(scope, (layout) => setTableVGroupsEnabledOp(layout, enabled));
},
tableVGroupPathForTable(scope: TableVGroupScope, tableName: string) {
tableVGroupPathForTable(scope: TableVGroupScope, tableName: string, rowType?: string) {
const resolved = resolveTableVGroupScope(scope);
return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName);
return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName, rowType);
},
};
});