diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 87cea522c8..cbfc117d42 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -516,14 +516,14 @@ async function toggle() { // particular, schema-level trigger/type groups have no tableName, so they // must use the generic object loader rather than the table-trigger loader. const databaseObjectGroup = !!objectTypesForGroupNode(node.type); - if (databaseObjectGroup && connectionStore.isTreeNodeChildrenLoaded(node.id)) { + if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) { node.isExpanded = !node.isExpanded; if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id); emit("node-toggled", node, wasExpanded); return; } - if (node.type === "group-extensions" && connectionStore.isTreeNodeChildrenLoaded(node.id)) { + if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) { node.isExpanded = !node.isExpanded; if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id); emit("node-toggled", node, wasExpanded); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index aebb86a0b8..4912cf3819 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -54,7 +54,7 @@ import { hexToRgba } from "@/lib/common/color"; import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay"; import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip"; import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection"; -import { isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState"; +import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { isWindows } from "@/lib/backend/platform"; import { flattenTree } from "@/composables/useFlatTree"; @@ -664,12 +664,11 @@ const isConnecting = computed(() => activeNode.value.type === "connection" && !! const isConnectionReadonly = computed(() => activeNode.value.type === "connection" && !!activeNode.value.connectionId && (connectionStore.getConfig(activeNode.value.connectionId)?.read_only ?? false)); const databaseOpenVisual = computed(() => { - const opened = isSidebarDatabaseOpened(activeNode.value, connectionStore.isTreeNodeChildrenLoaded); - const showsIndicator = activeNode.value.type === "database" && (opened || (!!activeNode.value.connectionId && activeNode.value.database != null && queryStore.openDatabaseKeys.has(`${activeNode.value.connectionId}\x00${activeNode.value.database}`))); + const databaseOpen = isSidebarDatabaseOpenForVisual(activeNode.value, connectionStore.isTreeNodeChildrenLoaded, queryStore.openDatabaseKeys); const infoClass = getIconInfo(activeNode.value)?.colorClass; return { - iconClass: activeNode.value.type !== "database" || opened ? infoClass : "text-muted-foreground/65", - showsIndicator, + iconClass: activeNode.value.type !== "database" || databaseOpen ? infoClass : "text-muted-foreground/65", + showsIndicator: databaseOpen, }; }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarDatabaseOpenState.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarDatabaseOpenState.spec.ts new file mode 100644 index 0000000000..81565b55ba --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarDatabaseOpenState.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isSidebarDatabaseOpenForVisual, sidebarDatabaseOpenKey } from "@/lib/sidebar/sidebarDatabaseOpenState"; +import type { TreeNode } from "@/types/database"; + +function databaseNode(id: string, connectionId: string, database: string): TreeNode { + return { + id, + label: database, + type: "database", + connectionId, + database, + }; +} + +describe("isSidebarDatabaseOpenForVisual", () => { + it("treats open editor tabs as open even before sidebar children are loaded", () => { + const node = databaseNode("db-node", "conn-1", "test1"); + const openKeys = new Set([sidebarDatabaseOpenKey("conn-1", "test1")]); + + expect(isSidebarDatabaseOpenForVisual(node, () => false, openKeys)).toBe(true); + }); + + it("stays closed when neither loaded nor referenced by an open tab", () => { + const node = databaseNode("db-node", "conn-1", "test1"); + + expect(isSidebarDatabaseOpenForVisual(node, () => false, new Set())).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts new file mode 100644 index 0000000000..85da197af8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker"; +import type { TreeNode } from "@/types/database"; + +function databaseNode(children: TreeNode[] = []): TreeNode { + return { + id: "conn:test1", + label: "test1", + type: "database", + connectionId: "conn", + database: "test1", + isExpanded: false, + children, + }; +} + +describe("treeNodeLoadedChildrenContentPresent", () => { + it("treats an empty grouped database shell as missing loaded content", () => { + expect(treeNodeLoadedChildrenContentPresent(databaseNode(), "grouped")).toBe(false); + }); + + it("accepts grouped databases with object-group placeholders", () => { + const node = databaseNode([ + { + id: "conn:test1:__tables", + label: "Tables", + type: "group-tables", + connectionId: "conn", + database: "test1", + isExpanded: false, + children: [], + }, + ]); + expect(treeNodeLoadedChildrenContentPresent(node, "grouped")).toBe(true); + }); + + it("accepts simple-mode databases with no objects as a valid empty load", () => { + expect(treeNodeLoadedChildrenContentPresent(databaseNode(), "simple")).toBe(true); + }); + + it("accepts loaded object groups with zero items", () => { + const node: TreeNode = { + id: "conn:test1:__tables", + label: "Tables", + type: "group-tables", + connectionId: "conn", + database: "test1", + isExpanded: false, + children: [], + }; + expect(treeNodeLoadedChildrenContentPresent(node, "grouped")).toBe(true); + }); +}); + +describe("simpleModeEmptyShellNeedsConfirmedLoad", () => { + it("requires confirmed empty only for simple database/schema shells", () => { + const db = databaseNode(); + expect(simpleModeEmptyShellNeedsConfirmedLoad(db, "simple")).toBe(true); + expect(simpleModeEmptyShellNeedsConfirmedLoad(db, "grouped")).toBe(false); + expect(simpleModeEmptyShellNeedsConfirmedLoad(databaseNode([{ id: "x", label: "t", type: "table", connectionId: "conn", database: "test1" }]), "simple")).toBe(false); + }); +}); + +describe("linked-server-schema empty shell", () => { + function linkedSchemaNode(children: TreeNode[] = []): TreeNode { + return { + id: "conn:linkeddb:dbo", + label: "dbo", + type: "linked-server-schema", + connectionId: "conn", + database: "linkeddb", + schema: "dbo", + isExpanded: false, + children, + }; + } + + it("treats an empty grouped linked-server-schema shell as missing loaded content", () => { + expect(treeNodeLoadedChildrenContentPresent(linkedSchemaNode(), "grouped")).toBe(false); + }); + + it("accepts simple-mode linked-server-schema with no objects as a valid empty load candidate", () => { + expect(treeNodeLoadedChildrenContentPresent(linkedSchemaNode(), "simple")).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts b/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts new file mode 100644 index 0000000000..4d959f2adb --- /dev/null +++ b/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { TreeNodeLoadRegistry, type TreeNodeLike } from "@/lib/metadata/treeNodeLoadHandle"; + +describe("TreeNodeLoadRegistry", () => { + it("allows apply and finish only for the current generation", () => { + const registry = new TreeNodeLoadRegistry(); + const node: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const findLive = (id: string) => (id === node.id ? node : null); + const isConnected = (id: string) => id === "c1"; + + const first = registry.begin(node); + expect(node.isLoading).toBe(true); + expect(first.targetNode(findLive, isConnected)).toBe(node); + + const second = registry.begin(node); + expect(first.isCurrent()).toBe(false); + expect(second.isCurrent()).toBe(true); + expect(first.targetNode(findLive, isConnected)).toBeNull(); + + first.finish(findLive); + expect(node.isLoading).toBe(true); + + second.finish(findLive); + expect(node.isLoading).toBe(false); + }); + + it("invalidates connection subtree loads and clears sticky spinners", () => { + const registry = new TreeNodeLoadRegistry(); + const db: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const root: TreeNodeLike = { id: "c1", connectionId: "c1", isLoading: false, children: [db] }; + const load = registry.begin(db); + expect(db.isLoading).toBe(true); + + registry.invalidateConnection("c1", root); + expect(load.isCurrent()).toBe(false); + expect(db.isLoading).toBe(false); + + const reclaimed = load.reclaim(db); + expect(reclaimed.isCurrent()).toBe(true); + expect(db.isLoading).toBe(true); + expect( + reclaimed.targetNode( + (id) => (id === db.id ? db : null), + (id) => id === "c1", + ), + ).toBe(db); + }); + + it("invalidates pruned descendant generations even when root no longer contains them", () => { + const registry = new TreeNodeLoadRegistry(); + const db: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const root: TreeNodeLike = { id: "c1", connectionId: "c1", isLoading: false, children: [db] }; + const load = registry.begin(db); + root.children = []; + + registry.invalidateConnection("c1", root); + expect(load.isCurrent()).toBe(false); + expect( + load.targetNode( + (id) => (id === db.id ? db : null), + () => true, + ), + ).toBeNull(); + }); + + it("observe tracks parent generation without claiming a load", () => { + const registry = new TreeNodeLoadRegistry(); + const parent: TreeNodeLike = { id: "c1:db:__tables", connectionId: "c1", isLoading: false, children: [] }; + const epoch = registry.observe(parent.id); + expect(epoch.isCurrent()).toBe(true); + expect(parent.isLoading).toBe(false); + + registry.invalidatePrefix(parent.id); + expect(epoch.isCurrent()).toBe(false); + }); + + it("invalidateDescendants leaves the parent generation current", () => { + const registry = new TreeNodeLoadRegistry(); + const parent: TreeNodeLike = { id: "c1", connectionId: "c1", isLoading: false, children: [] }; + const child: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const parentLoad = registry.begin(parent); + const childLoad = registry.begin(child); + + registry.invalidateDescendants(parent.id); + expect(parentLoad.isCurrent()).toBe(true); + expect(childLoad.isCurrent()).toBe(false); + }); + + it("rejects apply when disconnected even if generation is current", () => { + const registry = new TreeNodeLoadRegistry(); + const node: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const load = registry.begin(node); + expect( + load.targetNode( + (id) => (id === node.id ? node : null), + () => false, + ), + ).toBeNull(); + }); + + it("force begin supersedes prior handle without connection revision", () => { + const registry = new TreeNodeLoadRegistry(); + const node: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] }; + const findLive = (id: string) => (id === node.id ? node : null); + const isConnected = () => true; + + const stale = registry.begin(node); + const fresh = registry.begin(node); + expect(stale.targetNode(findLive, isConnected)).toBeNull(); + expect(fresh.targetNode(findLive, isConnected)).toBe(node); + stale.finish(findLive); + expect(node.isLoading).toBe(true); + fresh.finish(findLive); + expect(node.isLoading).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts b/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts new file mode 100644 index 0000000000..dc8631487c --- /dev/null +++ b/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts @@ -0,0 +1,125 @@ +/** + * Per-tree-node load generation: one handle owns both apply eligibility and spinner clear. + * Connection/reconnect invalidation bumps generations under a connection so stale loads + * cannot write children or clear a newer load's isLoading. + */ + +export type TreeNodeLike = { + id: string; + connectionId?: string; + isLoading?: boolean; + children?: TreeNodeLike[]; +}; + +export type TreeNodeLoadHandle = { + readonly nodeId: string; + readonly generation: number; + isCurrent(): boolean; + /** After ensureConnected may invalidate gens; reclaim ownership and keep spinner lit. */ + reclaim(node: TreeNodeLike): TreeNodeLoadHandle; + targetNode(findLive: (nodeId: string) => TreeNodeLike | null, isConnected: (connectionId: string) => boolean): TreeNodeLike | null; + finish(findLive: (nodeId: string) => TreeNodeLike | null): void; +}; + +/** Generation observer that does not claim a load or toggle isLoading. */ +export type TreeNodeLoadEpoch = { + isCurrent(): boolean; +}; + +export class TreeNodeLoadRegistry { + private readonly generations = new Map(); + + begin(node: TreeNodeLike): TreeNodeLoadHandle { + const generation = (this.generations.get(node.id) ?? 0) + 1; + this.generations.set(node.id, generation); + node.isLoading = true; + return this.createHandle(node.id, generation); + } + + /** Snapshot the current generation without claiming ownership (no spinner). */ + observe(nodeId: string): TreeNodeLoadEpoch { + // Register the id so later prefix invalidation (e.g. connection disconnect) can bump it + // even when this node never called begin(). + if (!this.generations.has(nodeId)) { + this.generations.set(nodeId, 0); + } + const generation = this.generations.get(nodeId)!; + return { + isCurrent: () => this.generations.get(nodeId) === generation, + }; + } + + /** Bump every recorded generation under nodeId (inclusive), including pruned IDs. */ + invalidatePrefix(nodeId: string): void { + // Always bump the prefix root, even when no load was recorded for it yet. + this.generations.set(nodeId, (this.generations.get(nodeId) ?? 0) + 1); + const prefix = `${nodeId}:`; + for (const id of [...this.generations.keys()]) { + if (id !== nodeId && id.startsWith(prefix)) { + this.generations.set(id, (this.generations.get(id) ?? 0) + 1); + } + } + } + + /** Bump recorded generations for descendants only (not the parent load itself). */ + invalidateDescendants(parentId: string): void { + const prefix = `${parentId}:`; + for (const id of [...this.generations.keys()]) { + if (id.startsWith(prefix)) { + this.generations.set(id, (this.generations.get(id) ?? 0) + 1); + } + } + } + + /** + * Invalidate all loads for a connection (including pruned node IDs) and clear sticky + * spinners on surviving live nodes. Always prefix-bump first so removed IDs cannot stay current. + */ + invalidateConnection(connectionId: string, root: TreeNodeLike | null): void { + this.invalidatePrefix(connectionId); + if (!root) return; + const stack: TreeNodeLike[] = [root]; + while (stack.length) { + const node = stack.pop()!; + node.isLoading = false; + if (node.children?.length) stack.push(...node.children); + } + } + + private isCurrent(nodeId: string, generation: number): boolean { + return this.generations.get(nodeId) === generation; + } + + private createHandle(nodeId: string, generation: number): TreeNodeLoadHandle { + const registry = this; + return { + nodeId, + generation, + isCurrent() { + return registry.isCurrent(nodeId, generation); + }, + reclaim(node: TreeNodeLike) { + if (node.id !== nodeId) { + return registry.begin(node); + } + if (registry.isCurrent(nodeId, generation)) { + node.isLoading = true; + return this; + } + return registry.begin(node); + }, + targetNode(findLive, isConnected) { + if (!registry.isCurrent(nodeId, generation)) return null; + const current = findLive(nodeId); + if (!current) return null; + if (current.connectionId && !isConnected(current.connectionId)) return null; + return current; + }, + finish(findLive) { + if (!registry.isCurrent(nodeId, generation)) return; + const current = findLive(nodeId); + if (current) current.isLoading = false; + }, + }; + } +} diff --git a/apps/desktop/src/lib/sidebar/sidebarDatabaseOpenState.ts b/apps/desktop/src/lib/sidebar/sidebarDatabaseOpenState.ts index 8fa06960e9..09072c52ae 100644 --- a/apps/desktop/src/lib/sidebar/sidebarDatabaseOpenState.ts +++ b/apps/desktop/src/lib/sidebar/sidebarDatabaseOpenState.ts @@ -1,9 +1,21 @@ import type { TreeNode } from "@/types/database"; +export function sidebarDatabaseOpenKey(connectionId: string, database: string): string { + return `${connectionId}\x00${database}`; +} + export function isSidebarDatabaseOpened(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean): boolean { return (node.type === "database" || node.type === "mongo-db" || node.type === "vector-database") && !!node.connectionId && node.database != null && isTreeNodeChildrenLoaded(node.id); } +/** Sidebar "open" visual: tree loaded and/or still referenced by an open editor tab. */ +export function isSidebarDatabaseOpenForVisual(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean, openDatabaseKeys: ReadonlySet): boolean { + if (node.type !== "database" || !node.connectionId || node.database == null) { + return false; + } + return isSidebarDatabaseOpened(node, isTreeNodeChildrenLoaded) || openDatabaseKeys.has(sidebarDatabaseOpenKey(node.connectionId, node.database)); +} + export function canCloseSidebarDatabaseConnection(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean, isDatabaseUsedByOpenTab: (connectionId: string, database: string) => boolean = () => false): boolean { return node.type === "database" && !!node.connectionId && node.database != null && (isTreeNodeChildrenLoaded(node.id) || isDatabaseUsedByOpenTab(node.connectionId, node.database)); } diff --git a/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts new file mode 100644 index 0000000000..55b437e26d --- /dev/null +++ b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts @@ -0,0 +1,27 @@ +import type { TreeNode } from "@/types/database"; +import { objectTypesForGroupNode } from "@/lib/table/tableTree"; + +export type SidebarObjectDisplayMode = "simple" | "grouped"; + +const TABLE_STRUCTURE_GROUP_TYPES = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-partitions"]); + +/** Whether a node's in-memory children match a valid "loaded" marker (scheme A). */ +export function treeNodeLoadedChildrenContentPresent(node: TreeNode, sidebarObjectDisplay: SidebarObjectDisplayMode): boolean { + const childCount = node.children?.length ?? 0; + if (node.type === "database" || node.type === "schema" || node.type === "linked-server-schema") { + if (childCount > 0) return true; + // Grouped mode always materializes object-group placeholders; empty means a stale shell. + return sidebarObjectDisplay === "simple"; + } + if (objectTypesForGroupNode(node.type) || TABLE_STRUCTURE_GROUP_TYPES.has(node.type) || node.type === "group-extensions") { + return true; + } + return childCount > 0; +} + +/** Simple-mode empty database/schema nodes need an explicit confirmed-empty load, not just a marker. */ +export function simpleModeEmptyShellNeedsConfirmedLoad(node: TreeNode, sidebarObjectDisplay: SidebarObjectDisplayMode): boolean { + if (sidebarObjectDisplay !== "simple") return false; + if (node.type !== "database" && node.type !== "schema" && node.type !== "linked-server-schema") return false; + return (node.children?.length ?? 0) === 0; +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 3b7a40dc45..612b669d45 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -813,4 +813,1206 @@ describe("connectionStore metadata loading", () => { expect(listObjects).toHaveBeenNthCalledWith(3, connection.id, "app", "app", ["PROCEDURE"], "p_0999", undefined, undefined); expect(storedProcedureGroup?.children?.map((node) => node.label)).toEqual(["p_0999"]); }); + + it("reloads a collapsed database after a forced connection database refresh", async () => { + const listDatabases = vi + .fn() + .mockResolvedValueOnce([ + { name: "test1", comment: null }, + { name: "test2", comment: null }, + ]) + .mockResolvedValueOnce([{ name: "test1", comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: false, + children: [], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadTables(connection.id, "test1"); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + expect(connectionNode.children![0].children?.length).toBeGreaterThan(0); + + connectionNode.children![0].isExpanded = false; + connectionNode.children![0].children = []; + + await store.loadDatabases(connection.id, { force: true }); + expect(listDatabases).toHaveBeenCalledTimes(1); + expect(connectionNode.children![0].children?.length ?? 0).toBe(0); + + await store.loadTables(connection.id, "test1"); + expect(connectionNode.children![0].children?.length).toBeGreaterThan(0); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + }); + + it("reloads a collapsed database in simple mode after a forced connection database refresh", async () => { + const tables: TableInfo[] = [{ name: "users", table_type: "TABLE", comment: null }]; + const listDatabases = vi + .fn() + .mockResolvedValueOnce([{ name: "test1", comment: null }]) + .mockResolvedValueOnce([{ name: "test1", comment: null }]); + const listTables = vi.fn().mockResolvedValue(tables); + const listObjects = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: false, + children: [], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadTables(connection.id, "test1"); + expect(listTables).toHaveBeenCalledTimes(1); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + + connectionNode.children![0].isExpanded = false; + connectionNode.children![0].children = []; + + await store.loadDatabases(connection.id, { force: true }); + listTables.mockClear(); + + await store.loadTables(connection.id, "test1"); + expect(connectionNode.children![0].children?.length ?? 0).toBeGreaterThan(0); + }); + + it("refetches a simple empty database after DDL-driven same-id shell refresh", async () => { + const listDatabases = vi.fn().mockResolvedValue([{ name: "test1", comment: null }]); + const listTables = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ name: "users", table_type: "TABLE", comment: null }]); + const listObjects = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: false, + children: [], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadTables(connection.id, "test1"); + expect(listTables).toHaveBeenCalledTimes(1); + expect(store.canUseLoadedTreeNodeToggle(store.treeNodes[0].children![0])).toBe(true); + + connectionNode.children![0].isExpanded = false; + connectionNode.children![0].children = []; + + // DDL created the first table; connection-level refresh rebuilds the same-id empty shell. + await store.loadDatabases(connection.id, { force: true }); + listTables.mockClear(); + + await store.loadTables(connection.id, "test1"); + expect(listTables).toHaveBeenCalledTimes(1); + expect(connectionNode.children![0].children?.some((child) => child.label === "users")).toBe(true); + }); + + it("does not refetch a legitimately empty simple-mode database on re-expand", async () => { + const listTables = vi.fn().mockResolvedValue([]); + const listObjects = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }, + ], + }, + ]; + + await store.loadTables(connection.id, "test1"); + expect(listTables).toHaveBeenCalledTimes(1); + expect(store.canUseLoadedTreeNodeToggle(store.treeNodes[0].children![0])).toBe(true); + + listTables.mockClear(); + await store.loadTables(connection.id, "test1"); + expect(listTables).not.toHaveBeenCalled(); + }); + + it("clears grouped object-group markers when the database shell goes stale", async () => { + const tables: TableInfo[] = [{ name: "users", table_type: "TABLE", comment: null }]; + const listTables = vi.fn().mockResolvedValue(tables); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const dbNode: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [dbNode], + }, + ]; + + await store.loadTables(connection.id, "test1"); + const tablesGroup = dbNode.children?.find((child) => child.type === "group-tables"); + expect(tablesGroup).toBeDefined(); + await store.loadObjectGroupChildren(tablesGroup!); + const tablesGroupInTree = dbNode.children?.find((child) => child.type === "group-tables"); + expect(tablesGroupInTree?.children?.length ?? 0).toBeGreaterThan(0); + const groupId = tablesGroupInTree!.id; + expect(store.isTreeNodeChildrenLoaded(groupId)).toBe(true); + expect(store.canUseLoadedTreeNodeToggle(tablesGroupInTree!)).toBe(true); + + dbNode.children = []; + listTables.mockClear(); + await store.loadTables(connection.id, "test1"); + + expect(store.isTreeNodeChildrenLoaded(groupId)).toBe(false); + expect(store.canUseLoadedTreeNodeToggle({ ...tablesGroup!, id: groupId, children: [] } as TreeNode)).toBe(false); + + const refreshedTablesGroup = dbNode.children?.find((child) => child.type === "group-tables"); + expect(refreshedTablesGroup).toBeDefined(); + await store.loadObjectGroupChildren(refreshedTablesGroup!); + expect(listTables).toHaveBeenCalledTimes(1); + expect(refreshedTablesGroup?.children?.length).toBeGreaterThan(0); + }); + + it("applies async table loads to the current tree node after an in-tree replacement", async () => { + const tables: TableInfo[] = [{ name: "users", table_type: "TABLE", comment: null }]; + let resolveTables!: (tables: TableInfo[]) => void; + const listTablesPromise = new Promise((resolve) => { + resolveTables = resolve; + }); + const listTables = vi.fn(() => listTablesPromise); + const listObjects = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const staleDbNode: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [staleDbNode], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const loadPromise = store.loadTables(connection.id, "test1"); + await vi.waitFor(() => expect(listTables).toHaveBeenCalled()); + const replacementDb: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + connectionNode.children = [replacementDb]; + resolveTables(tables); + await loadPromise; + + expect(staleDbNode.children?.length ?? 0).toBe(0); + expect(replacementDb.children?.length ?? 0).toBeGreaterThan(0); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + }); + + it("applies persisted cache to the current tree node after an in-tree replacement", async () => { + const cachedTable: TreeNode = { + id: "mysql-1:test1:users", + label: "users", + type: "table", + connectionId: "mysql-1", + database: "test1", + isExpanded: false, + children: [], + }; + let resolveCache!: (payload: unknown) => void; + const loadSchemaCachePromise = new Promise((resolve) => { + resolveCache = resolve; + }); + const loadSchemaCache = vi.fn(() => loadSchemaCachePromise); + const listTables = vi.fn(); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache, + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const staleDbNode: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [staleDbNode], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const loadPromise = store.loadTables(connection.id, "test1"); + await vi.waitFor(() => expect(loadSchemaCache).toHaveBeenCalled()); + const replacementDb: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + connectionNode.children = [replacementDb]; + resolveCache({ + version: 2, + cachedAt: new Date().toISOString(), + children: [cachedTable], + }); + await loadPromise; + + expect(listTables).not.toHaveBeenCalled(); + expect(staleDbNode.children?.length ?? 0).toBe(0); + expect(replacementDb.children?.length ?? 0).toBeGreaterThan(0); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + }); + + it("keeps database loaded markers when connection children are reordered only", async () => { + const listDatabases = vi.fn().mockResolvedValue([{ name: "test1", comment: null }]); + const listTables = vi.fn().mockResolvedValue([{ name: "users", table_type: "TABLE", comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }, + ], + }, + ]; + + await store.loadDatabases(connection.id, { force: true }); + await store.loadTables(connection.id, "test1"); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + listTables.mockClear(); + + await store.loadDatabases(connection.id); + expect(listDatabases).toHaveBeenCalledTimes(1); + expect(listTables).not.toHaveBeenCalled(); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + }); + + it("keeps database loaded markers when connection refresh passes utility-only children", async () => { + const listDatabases = vi + .fn() + .mockResolvedValueOnce([{ name: "test1", comment: null }]) + .mockResolvedValueOnce([]); + const listTables = vi.fn().mockResolvedValue([{ name: "users", table_type: "TABLE", comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [], + }, + ]; + + await store.loadDatabases(connection.id, { force: true }); + await store.loadTables(connection.id, "test1"); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + listTables.mockClear(); + + await store.loadDatabases(connection.id, { force: true }); + + expect(listDatabases).toHaveBeenCalledTimes(2); + expect(listTables).not.toHaveBeenCalled(); + expect(store.treeNodes[0].children?.some((child) => child.id === test1Id && child.type === "database")).toBe(true); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(true); + }); + + it("clears connection loading after disconnect even when metadata apply is skipped", async () => { + let resolveDatabases!: (value: { name: string; comment: null }[]) => void; + const listDatabases = vi.fn( + () => + new Promise<{ name: string; comment: null }[]>((resolve) => { + resolveDatabases = resolve; + }), + ); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = mysqlConnection(); + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const loadPromise = store.loadDatabases(connection.id, { force: true }); + await vi.waitFor(() => expect(listDatabases).toHaveBeenCalled()); + expect(connectionNode.isLoading).toBe(true); + store.connectedIds.delete(connection.id); + resolveDatabases([{ name: "app", comment: null }]); + await loadPromise; + + const liveConnection = store.treeNodes.find((node) => node.id === connection.id); + expect(liveConnection?.isLoading).toBe(false); + expect(connectionNode.isLoading).toBe(false); + }); + + it("ignores stale metadata results after disconnect and same-id reconnect", async () => { + let resolveOldTables!: (tables: TableInfo[]) => void; + let resolveNewTables!: (tables: TableInfo[]) => void; + let listTablesCalls = 0; + const listTables = vi.fn(() => { + listTablesCalls += 1; + if (listTablesCalls === 1) { + return new Promise((resolve) => { + resolveOldTables = resolve; + }); + } + return new Promise((resolve) => { + resolveNewTables = resolve; + }); + }); + const listObjects = vi.fn().mockResolvedValue([]); + const connectDb = vi.fn().mockResolvedValue("mysql-1"); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + connectDb, + disconnectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const staleLoad = store.loadTables(connection.id, "test1"); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(1)); + + await store.disconnect(connection.id); + expect(store.connectedIds.has(connection.id)).toBe(false); + + const reconnectedDb: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + isLoading: false, + }; + connectionNode.children = [reconnectedDb]; + await store.connect(connection); + expect(store.connectedIds.has(connection.id)).toBe(true); + + const freshLoad = store.loadTables(connection.id, "test1", undefined, { force: true }); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2)); + expect(reconnectedDb.isLoading).toBe(true); + + resolveOldTables([{ name: "stale_users", table_type: "TABLE", comment: null }]); + await staleLoad; + + expect(reconnectedDb.children?.some((child) => child.label === "stale_users")).toBe(false); + expect(reconnectedDb.isLoading).toBe(true); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(false); + + resolveNewTables([{ name: "fresh_users", table_type: "TABLE", comment: null }]); + await freshLoad; + expect(reconnectedDb.children?.some((child) => child.label === "fresh_users")).toBe(true); + expect(reconnectedDb.isLoading).toBe(false); + }); + + it("keeps database loading spinner when ensureConnected reconnects during the same load", async () => { + let resolveTables!: (tables: TableInfo[]) => void; + const listTables = vi.fn( + () => + new Promise((resolve) => { + resolveTables = resolve; + }), + ); + const listObjects = vi.fn().mockResolvedValue([]); + const checkConnectionHealth = vi.fn().mockRejectedValueOnce(new Error("pool dead")).mockResolvedValue(undefined); + const connectDb = vi.fn().mockResolvedValue("mysql-1"); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + connectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const dbNode: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [dbNode], + }, + ]; + + const loadPromise = store.loadTables(connection.id, "test1"); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(1)); + expect(checkConnectionHealth).toHaveBeenCalled(); + expect(connectDb).toHaveBeenCalled(); + expect(dbNode.isLoading).toBe(true); + + resolveTables([{ name: "users", table_type: "TABLE", comment: null }]); + await loadPromise; + + expect(dbNode.children?.some((child) => child.label === "users")).toBe(true); + expect(dbNode.isLoading).toBe(false); + }); + + it("clears sticky subtree loading when health-check reconnect bumps revision", async () => { + let resolveTables!: (tables: TableInfo[]) => void; + const listTables = vi.fn( + () => + new Promise((resolve) => { + resolveTables = resolve; + }), + ); + const listObjects = vi.fn().mockResolvedValue([]); + const checkConnectionHealth = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValue(new Error("pool dead")); + const connectDb = vi.fn().mockResolvedValue("mysql-1"); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + connectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listTables, + listObjects, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "simple"; + + const connection = mysqlConnection(); + const test1Id = `${connection.id}:test1`; + const dbNode: TreeNode = { + id: test1Id, + label: "test1", + type: "database", + connectionId: connection.id, + database: "test1", + isExpanded: true, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [dbNode], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const staleLoad = store.loadTables(connection.id, "test1"); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(1)); + expect(dbNode.isLoading).toBe(true); + expect(checkConnectionHealth).toHaveBeenCalledTimes(1); + + // Expire the health-check TTL, then ensureConnected: failed health โ†’ reconnect + // bumps the epoch while the same DB node object survives (unlike disconnect). + await new Promise((resolve) => setTimeout(resolve, 2100)); + await store.ensureConnected(connection.id); + expect(checkConnectionHealth).toHaveBeenCalledTimes(2); + expect(connectDb).toHaveBeenCalled(); + expect(store.connectedIds.has(connection.id)).toBe(true); + expect(connectionNode.children?.[0]).toBe(dbNode); + expect(dbNode.isLoading).toBe(false); + + resolveTables([{ name: "stale_users", table_type: "TABLE", comment: null }]); + await staleLoad; + + expect(dbNode.children?.some((child) => child.label === "stale_users")).toBe(false); + expect(dbNode.isLoading).toBe(false); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(false); + }); + + it("does not apply load-more results after the parent generation is invalidated", async () => { + const firstPage = Array.from({ length: 201 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + const refreshedFirstPage = firstPage.map((table) => ({ ...table, name: `fresh_${table.name}` })); + let resolveSecondPage!: (tables: TableInfo[]) => void; + const secondPagePromise = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const listTables = vi + .fn() + .mockResolvedValueOnce(firstPage) + .mockImplementationOnce(() => secondPagePromise) + .mockResolvedValueOnce(refreshedFirstPage); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 200; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: false, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [tablesGroup], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadObjectGroupChildren(tablesGroup); + const loadMoreNode = tablesGroup.children?.at(-1); + expect(loadMoreNode?.type).toBe("load-more"); + + const loadMorePromise = store.loadMoreObjectGroupChildren(loadMoreNode!); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2)); + + // Force-reload the parent group while load-more is in flight. Parent stays in-tree, but its + // generation advances; the stale page must not merge into the refreshed children. + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.some((child) => child.label?.startsWith("fresh_"))).toBe(true); + + resolveSecondPage([{ name: "t_0202", table_type: "TABLE", comment: null }]); + await loadMorePromise; + + expect(tablesGroup.children?.some((child) => child.label === "t_0202")).toBe(false); + expect(tablesGroup.children?.some((child) => child.label?.startsWith("fresh_"))).toBe(true); + }); + + it("merges load-more table pages onto the live group node after an in-tree replacement", async () => { + const firstPage = Array.from({ length: 201 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + let resolveSecondPage!: (tables: TableInfo[]) => void; + const secondPagePromise = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const listTables = vi + .fn() + .mockResolvedValueOnce(firstPage) + .mockImplementationOnce(() => secondPagePromise); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 200; + + const connection = mysqlConnection(); + const staleTablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: false, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [staleTablesGroup], + }, + ], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadObjectGroupChildren(staleTablesGroup); + const loadMoreNode = staleTablesGroup.children?.at(-1); + expect(loadMoreNode?.type).toBe("load-more"); + + const loadMorePromise = store.loadMoreObjectGroupChildren(loadMoreNode!); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2)); + + const concurrentTable: TreeNode = { + id: "mysql-1:app:__concurrent_marker", + label: "__concurrent_marker", + type: "table", + connectionId: connection.id, + database: "app", + isExpanded: false, + children: [], + }; + const liveTablesGroup: TreeNode = { + ...staleTablesGroup, + children: [...(staleTablesGroup.children?.filter((child) => child.type !== "load-more") ?? []), concurrentTable, loadMoreNode!], + }; + connectionNode.children![0].children = [liveTablesGroup]; + + resolveSecondPage([{ name: "t_0202", table_type: "TABLE", comment: null }]); + await loadMorePromise; + + expect(staleTablesGroup.children?.some((child) => child.label === "t_0202")).toBe(false); + expect(liveTablesGroup.children?.some((child) => child.id === concurrentTable.id)).toBe(true); + expect(liveTablesGroup.children?.some((child) => child.label === "t_0202")).toBe(true); + }); + + it("keeps table column loaded markers when a tables group appends via load-more", async () => { + const firstPage = Array.from({ length: 201 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + const listTables = vi + .fn() + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([{ name: "t_0202", table_type: "TABLE", comment: null }]); + const getColumns = vi.fn().mockResolvedValue([{ name: "id", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + getColumns, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 200; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: false, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + + await store.loadObjectGroupChildren(tablesGroup); + const firstTable = tablesGroup.children?.find((child) => child.type === "table"); + expect(firstTable).toBeDefined(); + await store.loadTableGroups(connection.id, "app", firstTable!.label, undefined, firstTable!.id); + const columnsGroupId = `${firstTable!.id}:__columns`; + await store.loadColumns(connection.id, "app", firstTable!.label, undefined, columnsGroupId); + expect(store.isTreeNodeChildrenLoaded(columnsGroupId)).toBe(true); + + const loadMoreNode = tablesGroup.children?.at(-1); + expect(loadMoreNode?.type).toBe("load-more"); + getColumns.mockClear(); + await store.loadMoreObjectGroupChildren(loadMoreNode!); + + expect(store.isTreeNodeChildrenLoaded(columnsGroupId)).toBe(true); + expect(getColumns).not.toHaveBeenCalled(); + }); + + it("applies SQL Server database object loads to the current tree node after an in-tree replacement", async () => { + let resolveSchemas!: (schemas: string[]) => void; + const listSchemas = vi.fn( + () => + new Promise((resolve) => { + resolveSchemas = resolve; + }), + ); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listSchemas, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped"; + + const connection = { + id: "sqlserver-1", + name: "SQL Server", + db_type: "sqlserver", + host: "127.0.0.1", + port: 1433, + username: "sa", + password: "", + database: "master", + } as ConnectionConfig; + const dbId = `${connection.id}:app`; + const staleDbNode: TreeNode = { + id: dbId, + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [staleDbNode], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + const loadPromise = store.loadSqlServerDatabaseObjects(connection.id, "app", { force: true }); + await vi.waitFor(() => expect(listSchemas).toHaveBeenCalled()); + const replacementDb: TreeNode = { + id: dbId, + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [], + }; + connectionNode.children = [replacementDb]; + resolveSchemas(["dbo"]); + await loadPromise; + + expect(staleDbNode.children?.length ?? 0).toBe(0); + expect(replacementDb.children?.length ?? 0).toBeGreaterThan(0); + expect(store.isTreeNodeChildrenLoaded(dbId)).toBe(true); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 242639632c..b84151a7b3 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -66,6 +66,7 @@ import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarN import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget"; +import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker"; import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth"; import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; import { migrateSqlServerLegacyCompatibilityConfig, requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; @@ -110,6 +111,7 @@ import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; +import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle"; import i18n from "@/i18n"; import type { MqAdminConfig } from "@/types/mq"; import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults"; @@ -328,6 +330,8 @@ export const useConnectionStore = defineStore("connection", () => { let agentDriversRefreshPromise: Promise | null = null; let localAgentDriversRefreshPromise: Promise | null = null; const loadedTreeNodeChildrenIds = ref>(new Set()); + /** Simple-mode database/schema nodes loaded successfully with zero objects (not refresh stale shells). */ + const confirmedEmptyTreeNodeIds = ref>(new Set()); const connectionErrors = ref>({}); const connectingIds = ref>(new Set()); const editingConnectionId = ref(null); @@ -424,10 +428,16 @@ export const useConnectionStore = defineStore("connection", () => { const successfulLocalConnectionAttempts = new Map(); const connectionStateRevisions = new Map(); const connectionErrorRevisions = new Map(); + const treeNodeLoads = new TreeNodeLoadRegistry(); let nextLocalConnectionAttempt = 0; let beforeConnectHandler: BeforeConnectHandler | null = null; let initFromDiskPromise: Promise | null = null; + // Loading/stale ownership stays on TreeNodeLoadRegistry (per-node generation), not the + // coordinator: many specialty loaders bypass runTreeMetadataLoad, and coordinator would + // otherwise need TreeNode/connected awareness. Keep coordinator for scope dedupe only. + // connectionStateRevision remains for disconnect/error cleanup; reconnect also invalidates + // tree loads via bump โ†’ treeNodeLoads.invalidateConnection. function runTreeMetadataLoad(scope: MetadataScopeInput, task: () => Promise, options?: LoadTreeOptions): Promise { return metadataLoadCoordinator.run(scope, task, { force: options?.force, kind: scope.kind }); } @@ -535,11 +545,18 @@ export const useConnectionStore = defineStore("connection", () => { function bumpConnectionStateRevision(connectionId: string): number { const revision = (connectionStateRevisions.get(connectionId) ?? 0) + 1; connectionStateRevisions.set(connectionId, revision); + // Invalidate per-node load generations under this connection (and clear sticky + // spinners on surviving nodes). Active loaders reclaim after ensureConnected. + treeNodeLoads.invalidateConnection(connectionId, findConnectionNode(connectionId)); return revision; } + function connectionStateRevision(connectionId: string): number { + return connectionStateRevisions.get(connectionId) ?? 0; + } + function isCurrentConnectionStateRevision(connectionId: string, revision: number): boolean { - return connectionStateRevisions.get(connectionId) === revision; + return connectionStateRevision(connectionId) === revision; } function isCurrentLocalConnectionAttempt(connectionId: string, attempt: number): boolean { @@ -1139,10 +1156,77 @@ export const useConnectionStore = defineStore("connection", () => { return nodes; } + function clearDescendantLoadedChildrenMarkers(parentId: string) { + const descendantPrefix = `${parentId}:`; + for (const id of loadedTreeNodeChildrenIds.value) { + if (id.startsWith(descendantPrefix)) loadedTreeNodeChildrenIds.value.delete(id); + } + for (const id of confirmedEmptyTreeNodeIds.value) { + if (id.startsWith(descendantPrefix)) confirmedEmptyTreeNodeIds.value.delete(id); + } + } + + /** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */ + function forgetTreeNodeLoadState(nodeId: string) { + clearLoadedChildrenCache(nodeId); + treeNodeLoads.invalidatePrefix(nodeId); + } + + function syncConfirmedEmptyTreeNodeId(parent: TreeNode) { + if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return; + const childCount = parent.children?.length ?? 0; + if (childCount === 0) confirmedEmptyTreeNodeIds.value.add(parent.id); + else confirmedEmptyTreeNodeIds.value.delete(parent.id); + } + + function sameConnectionMetadataChildIds(existing: TreeNode[] | undefined, next: TreeNode[]): boolean { + const previousIds = new Set(connectionMetadataChildren(existing).map((child) => child.id)); + const nextIds = new Set(connectionMetadataChildren(next).map((child) => child.id)); + if (previousIds.size !== nextIds.size) return false; + for (const id of previousIds) { + if (!nextIds.has(id)) return false; + } + return true; + } + + function directChildIdWasRemoved(existing: TreeNode[] | undefined, next: TreeNode[]): boolean { + const nextIds = new Set(next.map((child) => child.id)); + for (const child of existing ?? []) { + // Pagination placeholders are replaced on every page fetch, not structural removals. + if (child.type === "load-more") continue; + if (!nextIds.has(child.id)) return true; + } + return false; + } + + function shouldClearDescendantLoadedMarkers(parent: TreeNode, nextChildren: TreeNode[]): boolean { + if (parent.type === "connection") { + return !sameConnectionMetadataChildIds(parent.children, nextChildren); + } + if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema" || objectTypesForGroupNode(parent.type)) { + return directChildIdWasRemoved(parent.children, nextChildren); + } + return false; + } + function setChildren(parent: TreeNode, children: TreeNode[]) { + // Compare markers against the resolved child list (after connection preserve), not the raw loader payload. children = preserveExistingConnectionMetadataChildren(parent, children); + if (shouldClearDescendantLoadedMarkers(parent, children)) { + clearDescendantLoadedChildrenMarkers(parent.id); + // Parent load may still be current; only supersede descendant generations. + treeNodeLoads.invalidateDescendants(parent.id); + } if (parent.children && parent.children.length > 0) { const oldMap = new Map(parent.children.map((c) => [c.id, c] as const)); + const nextIds = new Set(children.map((child) => child.id)); + for (const [oldId, old] of oldMap) { + // Removed children keep no loaded markers; also bump generations so in-flight + // loads cannot apply if the same id is recreated later. + if (!nextIds.has(oldId) && old.type !== "load-more") { + forgetTreeNodeLoadState(oldId); + } + } children = children.map((child) => { const old = oldMap.get(child.id); if (old?.isLoading) { @@ -1158,6 +1242,13 @@ export const useConnectionStore = defineStore("connection", () => { if (old?.isExpanded) { return { ...child, isExpanded: true, children: old.children }; } + // Same-id collapsed database/schema shell replace (e.g. DDL โ†’ force loadDatabases): + // prior confirmed-empty markers belong to the discarded instance and must not skip + // the next expand reload. Do not do this for tables/groups โ€” load-more and list + // refresh must preserve nested loaded markers (columns, etc.). + if (old && (old.type === "database" || old.type === "schema" || old.type === "linked-server-schema")) { + forgetTreeNodeLoadState(child.id); + } return child; }); } @@ -1169,6 +1260,7 @@ export const useConnectionStore = defineStore("connection", () => { syncPinnedTreeState(children); parent.children = markRawLeafTreeNodes(children); loadedTreeNodeChildrenIds.value.add(parent.id); + syncConfirmedEmptyTreeNodeId(parent); } function removePinnedTreeNodes(nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean { @@ -1663,7 +1755,18 @@ export const useConnectionStore = defineStore("connection", () => { }; } - async function loadSimpleSupplementalObjectChildren(options: { node: TreeNode; nodeId: string; connectionId: string; database: string; querySchema: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; cacheKey: string; loadOptions?: LoadTreeOptions }) { + async function loadSimpleSupplementalObjectChildren(options: { + node: TreeNode; + nodeId: string; + connectionId: string; + database: string; + querySchema: string; + effectiveSchema?: string; + objectTypes: DatabaseObjectTreeKind[]; + cacheKey: string; + loadOptions?: LoadTreeOptions; + load: TreeNodeLoadHandle; + }) { if (options.objectTypes.length === 0) return; const searchFilter = activeTreeLoadSearchFilter(options.loadOptions); if (searchFilter) return; @@ -1693,13 +1796,14 @@ export const useConnectionStore = defineStore("connection", () => { }); if (supplementalChildren.length === 0) return; if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return; - if (!canApplyTreeMetadataResult(options.node)) return; + const targetNode = treeNodeLoadTarget(options.load); + if (!targetNode) return; - const loadMoreNodes = (options.node.children || []).filter((child) => child.type === "load-more"); - const currentChildren = withoutLoadMoreNodes(options.node.children); - const mergedChildren = mergeLocatedTreeChildren(options.node, currentChildren, supplementalChildren, options.connectionId, options.database); + const loadMoreNodes = (targetNode.children || []).filter((child) => child.type === "load-more"); + const currentChildren = withoutLoadMoreNodes(targetNode.children); + const mergedChildren = mergeLocatedTreeChildren(targetNode, currentChildren, supplementalChildren, options.connectionId, options.database); const nextChildren = [...mergedChildren, ...loadMoreNodes]; - setChildren(options.node, nextChildren); + setChildren(targetNode, nextChildren); await savePersistedTreeChildren(options.cacheKey, nextChildren); } catch (error) { // Some drivers only expose table metadata; keep the already-rendered table tree usable. @@ -1715,20 +1819,22 @@ export const useConnectionStore = defineStore("connection", () => { function refreshStaleTreeNode(node: TreeNode) { const searchFilter = sidebarSearchQuery.value || ""; if (searchFilter) return; - if (staleTreeRefreshIds.has(node.id)) return; - staleTreeRefreshIds.add(node.id); - const expandedIds = collectExpandedNodeIds([node]); - clearLoadedChildrenCache(node.id); + const liveNode = treeNodeInSidebarTree(node); + if (!liveNode) return; + if (staleTreeRefreshIds.has(liveNode.id)) return; + staleTreeRefreshIds.add(liveNode.id); + const expandedIds = collectExpandedNodeIds([liveNode]); + clearLoadedChildrenCache(liveNode.id); const refreshOptions = { force: true, expectedSidebarSearchQuery: searchFilter }; - void loadTreeNodeChildren(node, refreshOptions) + void loadTreeNodeChildren(liveNode, refreshOptions) .then(() => { if ((sidebarSearchQuery.value || "") !== searchFilter) return; - return restoreExpandedChildren(node, expandedIds, refreshOptions); + return restoreExpandedChildren(liveNode, expandedIds, refreshOptions); }) - .finally(() => staleTreeRefreshIds.delete(node.id)); + .finally(() => staleTreeRefreshIds.delete(liveNode.id)); } - async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise { + async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string, load: TreeNodeLoadHandle): Promise { const trace = createMetadataLoadTrace({ kind: "persisted-tree-cache", connectionId: node.connectionId, @@ -1750,9 +1856,16 @@ export const useConnectionStore = defineStore("connection", () => { logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss", resultCount: 0 }); return { hit: false, isStale: false }; } - const normalizedChildren = sortSidebarTreeChildrenForParent(node, childrenWithLinkedServers, config?.db_type); - setChildren(node, node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, normalizedChildren, node) : normalizedChildren); - node.isExpanded = true; + // Gate cache apply on the same per-node generation as network apply โ€” connection + // revision alone is not enough when a newer force-load supersedes this handle. + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) { + logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss" }); + return { hit: false, isStale: false }; + } + const normalizedChildren = sortSidebarTreeChildrenForParent(targetNode, childrenWithLinkedServers, config?.db_type); + setChildren(targetNode, targetNode.type === "connection" && targetNode.connectionId ? withSavedSqlRoot(targetNode.connectionId, normalizedChildren, targetNode) : normalizedChildren); + targetNode.isExpanded = true; logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", { cacheStatus: decoded.isStale ? "stale" : "hit", resultCount: normalizedChildren.length, @@ -1771,17 +1884,39 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, metadataChildren); } - function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { + function isTreeNodeLoadedChildrenUsable(node: TreeNode): boolean { + const sidebarObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay; + if (!treeNodeLoadedChildrenContentPresent(node, sidebarObjectDisplay)) return false; + if (simpleModeEmptyShellNeedsConfirmedLoad(node, sidebarObjectDisplay) && !confirmedEmptyTreeNodeIds.value.has(node.id)) { + return false; + } + return true; + } + + function canUseLoadedTreeNodeToggle(node: TreeNode): boolean { + return loadedTreeNodeChildrenIds.value.has(node.id) && isTreeNodeLoadedChildrenUsable(node); + } + + function useCachedChildren(node: TreeNode, options: LoadTreeOptions | undefined, load: TreeNodeLoadHandle): boolean { if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; + if (!load.isCurrent()) return false; if (node.type === "connection" && node.connectionId) { if (!hasConnectionMetadataChildren(node.children)) { clearLoadedChildrenCache(node.id); return false; } const normalizedChildren = sortSidebarTreeChildrenForParent(node, withSavedSqlRoot(node.connectionId, node.children || [], node), getConfig(node.connectionId)?.db_type); - setChildren(node, normalizedChildren); + const liveNode = treeNodeLoadTarget(load); + if (!liveNode) return false; + setChildren(liveNode, normalizedChildren); + liveNode.isExpanded = true; + } else if (!isTreeNodeLoadedChildrenUsable(node)) { + clearLoadedChildrenCache(node.id); + return false; } - node.isExpanded = true; + const liveNode = treeNodeLoadTarget(load); + if (!liveNode) return false; + liveNode.isExpanded = true; return true; } @@ -1829,6 +1964,7 @@ export const useConnectionStore = defineStore("connection", () => { function forgetLoadedChildrenIdsForSubtree(node: TreeNode) { loadedTreeNodeChildrenIds.value.delete(node.id); + confirmedEmptyTreeNodeIds.value.delete(node.id); for (const child of node.children ?? []) { forgetLoadedChildrenIdsForSubtree(child); } @@ -1848,10 +1984,36 @@ export const useConnectionStore = defineStore("connection", () => { return true; } - function canApplyTreeMetadataResult(node: TreeNode): boolean { - if (findNode(treeNodes.value, node.id) !== node) return false; - if (node.connectionId && !connectedIds.value.has(node.connectionId)) return false; - return true; + function treeNodeInSidebarTree(node: TreeNode): TreeNode | null { + return findNode(treeNodes.value, node.id); + } + + function beginTreeNodeLoad(node: TreeNode): TreeNodeLoadHandle { + return treeNodeLoads.begin(node); + } + + function reclaimTreeNodeLoad(load: TreeNodeLoadHandle, node: TreeNode): TreeNodeLoadHandle { + return load.reclaim(treeNodeInSidebarTree(node) ?? node); + } + + function treeNodeLoadTarget(load: TreeNodeLoadHandle): TreeNode | null { + return load.targetNode( + (nodeId) => findNode(treeNodes.value, nodeId), + (connectionId) => connectedIds.value.has(connectionId), + ) as TreeNode | null; + } + + function finishTreeNodeLoad(load: TreeNodeLoadHandle) { + load.finish((nodeId) => findNode(treeNodes.value, nodeId)); + } + + /** Apply to a related node only while this load handle is still current. */ + function treeNodeLoadRelatedTarget(load: TreeNodeLoadHandle, related: TreeNode): TreeNode | null { + if (!load.isCurrent()) return null; + const current = treeNodeInSidebarTree(related); + if (!current) return null; + if (current.connectionId && !connectedIds.value.has(current.connectionId)) return null; + return current; } function clearLoadedChildrenCache(prefix: string) { @@ -1860,6 +2022,11 @@ export const useConnectionStore = defineStore("connection", () => { loadedTreeNodeChildrenIds.value.delete(id); } } + for (const id of confirmedEmptyTreeNodeIds.value) { + if (id === prefix || id.startsWith(`${prefix}:`)) { + confirmedEmptyTreeNodeIds.value.delete(id); + } + } invalidateMetadataCachesByTreePrefix(prefix); const rawPrefix = `${prefix}:`; const encodedPrefix = `${schemaCacheKey(prefix)}:`; @@ -2670,20 +2837,21 @@ export const useConnectionStore = defineStore("connection", () => { async () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { if (options?.connectedOnly) { if (!connectedIds.value.has(connectionId)) return; } else { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); } - if (useCachedChildren(node, options)) return; + if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); if (config?.db_type === "duckdb") { const cacheKey = schemaCacheKey(connectionId, "duckdb-root"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -2692,15 +2860,16 @@ export const useConnectionStore = defineStore("connection", () => { const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]); const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node); if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, children); - await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else if (config && connectionUsesVisibleSchemaFilter(config)) { const schemaFilterConfig = config; const effectiveDb = schemaFilterConfig.database || ""; const cacheKey = schemaCacheKey(connectionId, effectiveDb, config.db_type === "oracle" ? "schemas-v2" : "schemas"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -2719,9 +2888,10 @@ export const useConnectionStore = defineStore("connection", () => { children: [], })); if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, withSavedSqlRoot(connectionId, schemaNodes, node)); - await savePersistedConnectionTreeChildren(cacheKey, node.children || schemaNodes); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode)); + await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || schemaNodes); } else { // Doris / StarRocks multi-catalog: when external catalogs exist, // render a catalog grouping layer (catalog โ†’ database โ†’ tables). @@ -2737,7 +2907,7 @@ export const useConnectionStore = defineStore("connection", () => { if (dorisCatalogs && dorisCatalogs.length > 1) { const cacheKey = schemaCacheKey(connectionId, "doris-catalogs"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -2756,13 +2926,14 @@ export const useConnectionStore = defineStore("connection", () => { })); const children = withSavedSqlRoot(connectionId, catalogNodes, node); if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, children); - await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else { const cacheKey = schemaCacheKey(connectionId, "databases-v2"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -2800,18 +2971,20 @@ export const useConnectionStore = defineStore("connection", () => { } const children = withSavedSqlRoot(connectionId, databaseNodes, node); if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, children); - await savePersistedConnectionTreeChildren(cacheKey, node.children || children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } } - node.isExpanded = true; + const liveNode = treeNodeLoadTarget(load); + if (liveNode) liveNode.isExpanded = true; if (options?.force) void loadSidebarDatabaseStorage(connectionId, { force: true }); } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, options, @@ -2828,12 +3001,13 @@ export const useConnectionStore = defineStore("connection", () => { if (metadataLoadCoordinator.has(scope)) return; const wasExpanded = !!node.isExpanded; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { await loadDatabases(connectionId, { connectedOnly: true }); } finally { - node.isExpanded = wasExpanded; - node.isLoading = false; + const liveNode = treeNodeInSidebarTree(node); + if (liveNode) liveNode.isExpanded = wasExpanded; + finishTreeNodeLoad(load); } } @@ -2841,9 +3015,10 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases"); const config = getConfig(connectionId); const visibleNames = filterVisibleDatabaseNames( @@ -2851,8 +3026,10 @@ export const useConnectionStore = defineStore("connection", () => { config?.visible_databases, ); const visibleNameSet = new Set(visibleNames); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, dbs @@ -2868,15 +3045,15 @@ export const useConnectionStore = defineStore("connection", () => { isExpanded: false, children: [], })), - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -2884,11 +3061,14 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, [ @@ -2911,15 +3091,15 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }, ], - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -2927,11 +3107,14 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, [ @@ -2945,15 +3128,15 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }, ], - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -2961,10 +3144,11 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); if (isFlatMqConnection(config)) { @@ -2972,7 +3156,9 @@ export const useConnectionStore = defineStore("connection", () => { // tenant and exposes virtual hosts as namespaces inside the console. Create a // synthetic child that opens the MQ admin console directly when clicked. const mqTenant = resolveMqSystemKindFromConnection(config) === "rabbitmq" ? RABBITMQ_MQ_TENANT : "_flat_mq"; - setChildren(node, [ + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, [ { id: schemaCacheKey(connectionId, "mq-tenant", mqTenant), label: "Topics", @@ -2985,8 +3171,10 @@ export const useConnectionStore = defineStore("connection", () => { } else { const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants"); const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim())); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, tenantNames.map((tenant) => ({ id: schemaCacheKey(connectionId, "mq-tenant", tenant), label: tenant, @@ -2996,12 +3184,13 @@ export const useConnectionStore = defineStore("connection", () => { })), ); } - node.isExpanded = true; + const liveNode = treeNodeLoadTarget(load); + if (liveNode) liveNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3009,10 +3198,11 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const namespaces = await api.nacosListNamespaces(connectionId); const sorted = [...namespaces].sort((left, right) => { @@ -3020,8 +3210,10 @@ export const useConnectionStore = defineStore("connection", () => { const rightLabel = right.namespaceShowName || right.namespace || "public"; return leftLabel.localeCompare(rightLabel); }); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, sorted.map((namespace) => { const value = namespace.namespace || ""; const label = namespace.namespaceShowName || value || "public"; @@ -3037,12 +3229,12 @@ export const useConnectionStore = defineStore("connection", () => { }; }), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3079,14 +3271,17 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.mongoListDatabases(connectionId), "MongoDB databases"); const config = getConfig(connectionId); const visibleDbs = filterDatabaseNamesForConnection(dbs, config); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(visibleDbs).map((db) => ({ @@ -3098,15 +3293,15 @@ export const useConnectionStore = defineStore("connection", () => { isExpanded: false, children: [], })), - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3133,12 +3328,15 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices"); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(indices).map((index) => ({ @@ -3149,15 +3347,15 @@ export const useConnectionStore = defineStore("connection", () => { database: "default", isExpanded: false, })), - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3165,12 +3363,15 @@ export const useConnectionStore = defineStore("connection", () => { const node = findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.documentListDatabases(connectionId), "Milvus databases"); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(dbs).map((db) => ({ @@ -3182,15 +3383,15 @@ export const useConnectionStore = defineStore("connection", () => { isExpanded: false, children: [], })), - node, + targetNode, ), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3202,9 +3403,10 @@ export const useConnectionStore = defineStore("connection", () => { const node = isMilvus && database ? findNode(treeNodes.value, `${connectionId}:${database}`) : findConnectionNode(connectionId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); const collections = await withMetadataLoadTimeout(connectionId, api.vectorListCollections(connectionId, effectiveDb), "vector collections"); const sorted = [...collections].sort((a, b) => a.name.localeCompare(b.name)); const collectionChildren = sorted.map((info) => ({ @@ -3217,13 +3419,15 @@ export const useConnectionStore = defineStore("connection", () => { isExpanded: false, meta: { dimension: info.dimension, collectionId: info.id } as VectorCollectionMeta, })); - setChildren(node, isMilvus && database ? collectionChildren : withSavedSqlRoot(connectionId, collectionChildren, node)); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, isMilvus && database ? collectionChildren : withSavedSqlRoot(connectionId, collectionChildren, targetNode)); + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3232,7 +3436,7 @@ export const useConnectionStore = defineStore("connection", () => { const node = findNode(treeNodes.value, nodeId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const collections = await api.mongoListCollections(connectionId, database); const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string)); @@ -3260,13 +3464,15 @@ export const useConnectionStore = defineStore("connection", () => { }, ...collectionChildren, ]; - setChildren(node, children); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3283,13 +3489,14 @@ export const useConnectionStore = defineStore("connection", () => { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const cacheKey = schemaCacheKey(connectionId, database, "schemas-v3"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -3331,15 +3538,16 @@ export const useConnectionStore = defineStore("connection", () => { children.push(buildExtensionManagementNode(connectionId, database)); } if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); await savePersistedTreeChildren(cacheKey, children); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, options, @@ -3350,14 +3558,15 @@ export const useConnectionStore = defineStore("connection", () => { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-schemas-simple-v4" : "sqlserver-schemas-grouped-v4"); if (!options?.force) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -3368,29 +3577,34 @@ export const useConnectionStore = defineStore("connection", () => { const schemas = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), config, database); const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas); if (isSidebarSearchQueryChanged(options)) return; - setChildren(node, children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); await savePersistedTreeChildren(cacheKey, children); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } async function loadSqlServerLinkedServers(connectionId: string, options?: LoadTreeOptions) { const node = findNode(treeNodes.value, sqlServerLinkedRootId(connectionId)); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const database = sqlServerLinkedRuntimeDatabase(config); const linkedServers = await api.listSqlServerLinkedServers(connectionId); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, linkedServers.map((server) => ({ id: sqlServerLinkedServerId(connectionId, server.name), label: server.name, @@ -3403,12 +3617,12 @@ export const useConnectionStore = defineStore("connection", () => { children: [], })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3416,14 +3630,17 @@ export const useConnectionStore = defineStore("connection", () => { if (!node.connectionId || !node.linkedServer) return; const connectionId = node.connectionId; const server = node.linkedServer; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const catalogs = await api.listSqlServerLinkedServerCatalogs(connectionId, server); const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(connectionId)); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, catalogs .filter((catalog) => catalog.name.trim()) .map((catalog) => ({ @@ -3438,54 +3655,57 @@ export const useConnectionStore = defineStore("connection", () => { children: [], })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } async function loadSqlServerLinkedServerSchemas(node: TreeNode, options?: LoadTreeOptions) { if (!node.connectionId || !node.linkedServer || !node.linkedCatalog) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(node.connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const schemas = await api.listSqlServerLinkedServerSchemas(node.connectionId, node.linkedServer, node.linkedCatalog); const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(node.connectionId)); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, sortSidebarNames(schemas) .filter((schema) => schema.trim()) .map((schema) => { const encodedSchema = encodeSqlServerLinkedSchema({ - server: node.linkedServer!, - catalog: node.linkedCatalog!, + server: targetNode.linkedServer!, + catalog: targetNode.linkedCatalog!, schema, }); return { - id: `${node.connectionId}:${database}:${encodedSchema}`, + id: `${targetNode.connectionId}:${database}:${encodedSchema}`, label: schema, type: "linked-server-schema" as const, - connectionId: node.connectionId, + connectionId: targetNode.connectionId, database, schema: encodedSchema, - linkedServer: node.linkedServer, - linkedCatalog: node.linkedCatalog, + linkedServer: targetNode.linkedServer, + linkedCatalog: targetNode.linkedCatalog, linkedSchema: schema, isExpanded: false, children: [], }; }), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(node.connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3494,10 +3714,11 @@ export const useConnectionStore = defineStore("connection", () => { if (!node.connectionId || !node.catalog) return; const connectionId = node.connectionId; const catalog = node.catalog; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases"); let databaseNodes: TreeNode[]; @@ -3532,14 +3753,15 @@ export const useConnectionStore = defineStore("connection", () => { }); } if (isSidebarSearchQueryChanged(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - setChildren(node, databaseNodes); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, databaseNodes); + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -3569,10 +3791,11 @@ export const useConnectionStore = defineStore("connection", () => { extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined, }, async () => { - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const searchFilter = activeTreeLoadSearchFilter(options); const tableNameFilter = activeTableNameFilterForScope({ connectionId, @@ -3582,7 +3805,7 @@ export const useConnectionStore = defineStore("connection", () => { }); const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v5"); if (!options?.force && !searchFilter && !tableNameFilter) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -3607,18 +3830,20 @@ export const useConnectionStore = defineStore("connection", () => { children = [...children, buildLoadMoreNode(node, pageSize, pageSize)]; } if (isTreeLoadSearchChanged(searchFilter, options)) return; - if (!canApplyTreeMetadataResult(node)) return; - node.objectCount = children.filter((child) => child.type !== "load-more").length; - setChildren(node, children); + if (!tableNameFilterRevisionMatches(options)) return; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + targetNode.objectCount = children.filter((child) => child.type !== "load-more").length; + setChildren(targetNode, children); if (!searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, options, @@ -3648,10 +3873,11 @@ export const useConnectionStore = defineStore("connection", () => { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7"); const searchFilter = activeTreeLoadSearchFilter(options); @@ -3666,7 +3892,7 @@ export const useConnectionStore = defineStore("connection", () => { }); const isSidebarTableSearch = !!options?.sidebarTableSearchParentId; if (!options?.force && !searchFilter && !tableNameFilter) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -3705,16 +3931,18 @@ export const useConnectionStore = defineStore("connection", () => { } } if (isTreeLoadSearchChanged(searchFilter, options)) return; - if (!canApplyTreeMetadataResult(node)) return; - if (nextObjectCount !== undefined) node.objectCount = nextObjectCount; - setChildren(node, children); + if (!tableNameFilterRevisionMatches(options)) return; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + if (nextObjectCount !== undefined) targetNode.objectCount = nextObjectCount; + setChildren(targetNode, children); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } - node.isExpanded = true; + targetNode.isExpanded = true; if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) { void loadSimpleSupplementalObjectChildren({ - node, + node: targetNode, nodeId, connectionId, database, @@ -3723,13 +3951,14 @@ export const useConnectionStore = defineStore("connection", () => { objectTypes: nonTableObjectTypes, cacheKey, loadOptions: options, + load, }); } } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, options, @@ -3757,10 +3986,11 @@ export const useConnectionStore = defineStore("connection", () => { }, async () => { if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(node.connectionId); - if (useCachedChildren(node, options)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, options, load)) return; const objectTypes = objectTypesForGroupNode(node.type); const parentNodeId = objectGroupRefreshParentId(node); if (!objectTypes || !parentNodeId) return; @@ -3779,7 +4009,7 @@ export const useConnectionStore = defineStore("connection", () => { }); const isSidebarTableSearch = !!options?.sidebarTableSearchParentId; if (!options?.force && !searchFilter && !tableNameFilter) { - const cached = await loadPersistedTreeChildren(node, cacheKey); + const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; @@ -3821,18 +4051,19 @@ export const useConnectionStore = defineStore("connection", () => { } if (isTreeLoadSearchChanged(searchFilter, options)) return; if (!tableNameFilterRevisionMatches(options)) return; - if (!canApplyTreeMetadataResult(node)) return; - node.objectCount = nextObjectCount; - setChildren(node, children); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + targetNode.objectCount = nextObjectCount; + setChildren(targetNode, children); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(node.connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, options, @@ -3861,9 +4092,13 @@ export const useConnectionStore = defineStore("connection", () => { driverProfile: metadataDriverProfile(configForScope), }, async () => { - node.isLoading = true; + let load = beginTreeNodeLoad(node); + // Parent writes must honor the parent's generation too โ€” load-more begins on the + // placeholder node, so a replaced/invalidated parent must reject the merge. + const parentEpoch = treeNodeLoads.observe(parent.id); try { await ensureConnected(parentConnectionId); + load = reclaimTreeNodeLoad(load, node); if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") { const parentDatabase = parent.database; if (!parentDatabase) return; @@ -3881,14 +4116,15 @@ export const useConnectionStore = defineStore("connection", () => { pageSize: loadMore.pageSize, force: false, }); - const currentChildren = withoutTableTreeLoadMoreNodes(parent.children); + const targetParent = treeNodeLoadRelatedTarget(load, parent); + if (!targetParent || !parentEpoch.isCurrent()) return; + const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children); const mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase); - const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; - if (!canApplyTreeMetadataResult(parent)) return; - parent.objectCount = mergedChildren.length; - setChildren(parent, nextChildren); + const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; + targetParent.objectCount = mergedChildren.length; + setChildren(targetParent, nextChildren); await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", "objects-simple-v6"), nextChildren); - parent.isExpanded = true; + targetParent.isExpanded = true; return; } const objectTypes = objectTypesForGroupNode(parent.type); @@ -3914,9 +4150,11 @@ export const useConnectionStore = defineStore("connection", () => { pageSize: loadMore.pageSize, force: false, }); - const currentChildren = withoutTableTreeLoadMoreNodes(parent.children); + const targetParent = treeNodeLoadRelatedTarget(load, parent); + if (!targetParent || !parentEpoch.isCurrent()) return; + const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children); mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase); - nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; + nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; } else { const page = await loadPagedObjectGroupChildren({ node: parent, @@ -3928,20 +4166,28 @@ export const useConnectionStore = defineStore("connection", () => { pageSize: loadMore.pageSize, force: false, }); - const currentChildren = withoutLoadMoreNodes(parent.children); - mergedChildren = mergeLocatedTreeChildren(parent, currentChildren, page.children, parentConnectionId, parentDatabase); - nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize)] : mergedChildren; + const targetParent = treeNodeLoadRelatedTarget(load, parent); + if (!targetParent || !parentEpoch.isCurrent()) return; + const currentChildren = withoutLoadMoreNodes(targetParent.children); + mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, parentConnectionId, parentDatabase); + nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize)] : mergedChildren; + targetParent.objectCount = mergedChildren.length; + setChildren(targetParent, nextChildren); + await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + targetParent.isExpanded = true; + return; } - if (!canApplyTreeMetadataResult(parent)) return; - parent.objectCount = mergedChildren.length; - setChildren(parent, nextChildren); - await savePersistedTreeChildren(objectGroupCacheKey(parent), nextChildren); - parent.isExpanded = true; + const targetParent = treeNodeLoadRelatedTarget(load, parent); + if (!targetParent || !parentEpoch.isCurrent()) return; + targetParent.objectCount = mergedChildren.length; + setChildren(targetParent, nextChildren); + await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + targetParent.isExpanded = true; } catch (e) { recordMetadataLoadError(parentConnectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } }, ); @@ -3950,10 +4196,11 @@ export const useConnectionStore = defineStore("connection", () => { async function loadExtensions(connectionId: string, database: string) { const node = findNode(treeNodes.value, `${connectionId}:${database}:__extensions`); if (!node) return; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); - if (useCachedChildren(node)) return; + load = reclaimTreeNodeLoad(load, node); + if (useCachedChildren(node, undefined, load)) return; const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database), "extensions"); const children: TreeNode[] = extensions.map((ext) => ({ id: `${node.id}:${ext.schema || ""}:${ext.name}`, @@ -3966,14 +4213,16 @@ export const useConnectionStore = defineStore("connection", () => { meta: ext, isExpanded: false, })); - setChildren(node, children); - node.objectCount = children.length; - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + targetNode.objectCount = children.length; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4006,27 +4255,34 @@ export const useConnectionStore = defineStore("connection", () => { const parentId = target.schema ? `${target.connectionId}:${target.database}:${target.schema}` : `${target.connectionId}:${target.database}`; const parent = findNode(treeNodes.value, parentId); if (!parent) return false; - const page = await loadPagedSimpleTableChildren({ - nodeId: parentId, - connectionId: target.connectionId, - database: target.database, - querySchema, - effectiveSchema, - nonTableObjectTypes: [], - offset: 0, - pageSize, - searchFilter: target.tableName, - force: false, - }); - if (!page.children.length) return false; - const currentChildren = withoutLoadMoreNodes(parent.children); - const loadMoreNodes = (parent.children || []).filter((child) => child.type === "load-more"); - const mergedChildren = mergeLocatedTreeChildren(parent, currentChildren, page.children, target.connectionId, target.database); - if (!canApplyTreeMetadataResult(parent)) return false; - setChildren(parent, [...mergedChildren, ...loadMoreNodes]); - parent.objectCount = Math.max(parent.objectCount ?? currentChildren.length, mergedChildren.length); - parent.isExpanded = true; - return true; + let load = beginTreeNodeLoad(parent); + try { + load = reclaimTreeNodeLoad(load, parent); + const page = await loadPagedSimpleTableChildren({ + nodeId: parentId, + connectionId: target.connectionId, + database: target.database, + querySchema, + effectiveSchema, + nonTableObjectTypes: [], + offset: 0, + pageSize, + searchFilter: target.tableName, + force: false, + }); + if (!page.children.length) return false; + const targetParent = treeNodeLoadTarget(load); + if (!targetParent) return false; + const currentChildren = withoutLoadMoreNodes(targetParent.children); + const loadMoreNodes = (targetParent.children || []).filter((child) => child.type === "load-more"); + const mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, target.connectionId, target.database); + setChildren(targetParent, [...mergedChildren, ...loadMoreNodes]); + targetParent.objectCount = Math.max(targetParent.objectCount ?? currentChildren.length, mergedChildren.length); + targetParent.isExpanded = true; + return true; + } finally { + finishTreeNodeLoad(load); + } } const matchingGroups = findTreeNodes(treeNodes.value, (node) => { @@ -4038,27 +4294,34 @@ export const useConnectionStore = defineStore("connection", () => { const parentNodeId = objectGroupRefreshParentId(group); if (!objectTypes || !parentNodeId) continue; - const page = await loadPagedTableGroupChildren({ - node: group, - parentNodeId, - querySchema, - effectiveSchema, - objectTypes, - offset: 0, - pageSize, - searchFilter: target.tableName, - force: false, - }); - if (!page.children.length) continue; + let load = beginTreeNodeLoad(group); + try { + load = reclaimTreeNodeLoad(load, group); + const page = await loadPagedTableGroupChildren({ + node: group, + parentNodeId, + querySchema, + effectiveSchema, + objectTypes, + offset: 0, + pageSize, + searchFilter: target.tableName, + force: false, + }); + if (!page.children.length) continue; - const currentChildren = withoutLoadMoreNodes(group.children); - const loadMoreNodes = (group.children || []).filter((child) => child.type === "load-more"); - const mergedChildren = mergeLocatedTreeChildren(group, currentChildren, page.children, target.connectionId, target.database); - if (!canApplyTreeMetadataResult(group)) continue; - setChildren(group, [...mergedChildren, ...loadMoreNodes]); - group.objectCount = Math.max(group.objectCount ?? currentChildren.length, mergedChildren.length); - group.isExpanded = true; - loaded = true; + const targetGroup = treeNodeLoadTarget(load); + if (!targetGroup) continue; + const currentChildren = withoutLoadMoreNodes(targetGroup.children); + const loadMoreNodes = (targetGroup.children || []).filter((child) => child.type === "load-more"); + const mergedChildren = mergeLocatedTreeChildren(targetGroup, currentChildren, page.children, target.connectionId, target.database); + setChildren(targetGroup, [...mergedChildren, ...loadMoreNodes]); + targetGroup.objectCount = Math.max(targetGroup.objectCount ?? currentChildren.length, mergedChildren.length); + targetGroup.isExpanded = true; + loaded = true; + } finally { + finishTreeNodeLoad(load); + } } return loaded; @@ -4069,25 +4332,32 @@ export const useConnectionStore = defineStore("connection", () => { async function loadAllObjectGroupChildren(parent: TreeNode) { if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return; if (!objectTypesForGroupNode(parent.type)) return; - parent.isLoading = true; + let load = beginTreeNodeLoad(parent); try { await ensureConnected(parent.connectionId); - if (!isTreeNodeChildrenLoaded(parent.id)) { - await loadObjectGroupChildren(parent); + load = reclaimTreeNodeLoad(load, parent); + const liveParent = treeNodeLoadTarget(load); + if (!liveParent) return; + if (!isTreeNodeChildrenLoaded(liveParent.id)) { + await loadObjectGroupChildren(liveParent); } + if (!load.isCurrent()) return; - let loadMoreNode = findTreeNodes(parent.children ?? [], (child) => child.type === "load-more")[0]; + let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0]; while (loadMoreNode?.loadMore) { - loadMoreNode.isLoading = true; await loadMoreObjectGroupChildren(loadMoreNode); - loadMoreNode = findTreeNodes(parent.children ?? [], (child) => child.type === "load-more")[0]; + if (!load.isCurrent()) return; + const currentParent = treeNodeLoadTarget(load); + if (!currentParent) return; + loadMoreNode = findTreeNodes(currentParent.children ?? [], (child) => child.type === "load-more")[0]; } - parent.isExpanded = true; + const finishedParent = treeNodeLoadTarget(load); + if (finishedParent) finishedParent.isExpanded = true; } catch (e) { recordMetadataLoadError(parent.connectionId, e); throw e; } finally { - parent.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4132,48 +4402,36 @@ export const useConnectionStore = defineStore("connection", () => { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}` : `${connectionId}:${database}:${table}`); const node = findNode(treeNodes.value, parentId); if (!node) return; + let load = beginTreeNodeLoad(node); - const children: TreeNode[] = [ - ...tablePartitionGroups(node), - { - id: `${parentId}:__columns`, - label: "tree.columns", - type: "group-columns", - connectionId, - database, - schema, - catalog, - tableName: table, - isExpanded: false, - children: [], - }, - ]; + try { + const children: TreeNode[] = [ + ...tablePartitionGroups(node), + { + id: `${parentId}:__columns`, + label: "tree.columns", + type: "group-columns", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }, + ]; - const config = getConfig(connectionId); - const effectiveDbType = effectiveDatabaseTypeForConnection(config); - const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType); - const isXugu = effectiveDbType === "xugu"; - const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata()); - if (supportsXuguChildMetadata) { - children.push({ - id: `${parentId}:__constraints`, - label: "tree.constraints", - type: "group-constraints", - connectionId, - database, - schema, - catalog, - tableName: table, - isExpanded: false, - children: [], - }); - } - if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) { - if (metadataCapabilities.indexes && !isXugu) { + const config = getConfig(connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType); + const isXugu = effectiveDbType === "xugu"; + const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata()); + load = reclaimTreeNodeLoad(load, node); + if (supportsXuguChildMetadata) { children.push({ - id: `${parentId}:__indexes`, - label: "tree.indexes", - type: "group-indexes", + id: `${parentId}:__constraints`, + label: "tree.constraints", + type: "group-constraints", connectionId, database, schema, @@ -4183,38 +4441,8 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }); } - } - if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { - if (metadataCapabilities.foreignKeys) { - children.push({ - id: `${parentId}:__fkeys`, - label: "tree.foreignKeys", - type: "group-fkeys", - connectionId, - database, - schema, - catalog, - tableName: table, - isExpanded: false, - children: [], - }); - } - if (metadataCapabilities.triggers) { - children.push({ - id: `${parentId}:__triggers`, - label: "tree.triggers", - type: "group-triggers", - connectionId, - database, - schema, - catalog, - tableName: table, - isExpanded: false, - children: [], - }); - } - if (isXugu) { - if (metadataCapabilities.indexes) { + if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) { + if (metadataCapabilities.indexes && !isXugu) { children.push({ id: `${parentId}:__indexes`, label: "tree.indexes", @@ -4228,12 +4456,42 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }); } - if (supportsXuguChildMetadata) { - children.push( - { - id: `${parentId}:__table-partitions`, - label: "tree.partitions", - type: "group-table-partitions", + } + if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { + if (metadataCapabilities.foreignKeys) { + children.push({ + id: `${parentId}:__fkeys`, + label: "tree.foreignKeys", + type: "group-fkeys", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }); + } + if (metadataCapabilities.triggers) { + children.push({ + id: `${parentId}:__triggers`, + label: "tree.triggers", + type: "group-triggers", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }); + } + if (isXugu) { + if (metadataCapabilities.indexes) { + children.push({ + id: `${parentId}:__indexes`, + label: "tree.indexes", + type: "group-indexes", connectionId, database, schema, @@ -4241,26 +4499,46 @@ export const useConnectionStore = defineStore("connection", () => { tableName: table, isExpanded: false, children: [], - }, - { - id: `${parentId}:__table-subpartitions`, - label: "tree.subpartitions", - type: "group-table-subpartitions", - connectionId, - database, - schema, - catalog, - tableName: table, - isExpanded: false, - children: [], - }, - ); + }); + } + if (supportsXuguChildMetadata) { + children.push( + { + id: `${parentId}:__table-partitions`, + label: "tree.partitions", + type: "group-table-partitions", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }, + { + id: `${parentId}:__table-subpartitions`, + label: "tree.subpartitions", + type: "group-table-subpartitions", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }, + ); + } } } - } - setChildren(node, children); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, children); + targetNode.isExpanded = true; + } finally { + finishTreeNodeLoad(load); + } } async function loadColumns(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { @@ -4268,12 +4546,14 @@ export const useConnectionStore = defineStore("connection", () => { const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "mongodb") { const fields = await listMongoCompletionFields(connectionId, database, table); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, fields.map((field) => { const column = { name: field.name, @@ -4294,13 +4574,15 @@ export const useConnectionStore = defineStore("connection", () => { }; }), ); - node.isExpanded = true; + targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const columns = await api.getColumns(connectionId, database, querySchema, table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, columns.map((col) => ({ id: `${parentId}:${col.name}`, label: `${col.name} (${col.data_type})`, @@ -4312,12 +4594,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: col, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4326,18 +4608,22 @@ export const useConnectionStore = defineStore("connection", () => { const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.indexes) { - setChildren(node, []); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, []); + targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, indexes.map((idx) => ({ id: `${parentId}:${idx.name}`, label: `${idx.name} (${idx.columns.join(", ")})`, @@ -4349,12 +4635,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: idx, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4363,12 +4649,14 @@ export const useConnectionStore = defineStore("connection", () => { const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.foreignKeys) { - setChildren(node, []); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, []); + targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); @@ -4377,8 +4665,10 @@ export const useConnectionStore = defineStore("connection", () => { completionForeignKeysCache.value[cacheKey] = fkeys; evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); indexCompletionForeignKeys(connectionId, database, table, schema, sqlCompletionForeignKeys(fkeys)); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, fkeys.map((fk) => ({ id: `${parentId}:${fk.name}`, label: `${fk.column} โ†’ ${fk.ref_table}.${fk.ref_column}`, @@ -4390,12 +4680,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: fk, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4404,18 +4694,22 @@ export const useConnectionStore = defineStore("connection", () => { const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.triggers) { - setChildren(node, []); - node.isExpanded = true; + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, []); + targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const triggers = await api.listTriggers(connectionId, database, querySchema, table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, triggers.map((tr) => ({ id: `${parentId}:${tr.name}`, label: `${tr.name} (${tr.timing} ${tr.event})`, @@ -4428,12 +4722,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: tr, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4441,11 +4735,13 @@ export const useConnectionStore = defineStore("connection", () => { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__constraints`; const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, constraints.map((constraint) => ({ id: `${parentId}:${constraint.name}`, label: `${constraint.name} (${constraint.constraint_type})${constraint.valid ? "" : " ยท INVALID"}`, @@ -4457,12 +4753,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: constraint, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4470,11 +4766,13 @@ export const useConnectionStore = defineStore("connection", () => { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-partitions`; const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`, label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, @@ -4486,12 +4784,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: partition, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -4499,11 +4797,13 @@ export const useConnectionStore = defineStore("connection", () => { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-subpartitions`; const node = findNode(treeNodes.value, parentId); if (!node) return; - node.isLoading = true; + const load = beginTreeNodeLoad(node); try { const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; setChildren( - node, + targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`, label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, @@ -4515,12 +4815,12 @@ export const useConnectionStore = defineStore("connection", () => { meta: partition, })), ); - node.isExpanded = true; + targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } @@ -6173,6 +6473,7 @@ export const useConnectionStore = defineStore("connection", () => { ensureConnected, loadConnectedConnectionRootForSidebarSearch, isTreeNodeChildrenLoaded, + canUseLoadedTreeNodeToggle, releaseCollapsedTreeNodeChildren, setBeforeConnectHandler, initFromDisk, diff --git a/docs/data/contributors.json b/docs/data/contributors.json index 81d5b1bd06..5f4bf5da0f 100644 --- a/docs/data/contributors.json +++ b/docs/data/contributors.json @@ -1,7 +1,7 @@ { "repository": "t8y2/dbx", - "generatedAt": "2026-07-26T18:22:21.473Z", - "stars": 11739, + "generatedAt": "2026-07-26T18:49:37.991Z", + "stars": 11742, "contributors": [ { "login": "t8y2",