fix(sidebar): reload metadata after stale tree refresh
This commit is contained in:
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
Reference in new issue
Block a user