fix(sidebar): reload metadata after stale tree refresh

This commit is contained in:
AiLi authored and GitHub committed 2026-07-27 23:52:39 +08:00
1 parent 04e2667095
commit 59f9d5b2a1
11 files changed
+2269 -374

No files matched your search

@@ -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);
@@ -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,
};
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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<string, number>();
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;
},
};
}
}
@@ -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<string>): 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));
}
@@ -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<TreeNode["type"]>(["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;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff