diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 0720c6dbe8..36932ed8bd 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -25,6 +25,7 @@ import { } from "@/lib/sidebar/sidebarSearchTree"; import { createSidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch"; import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex"; +import { needsSidebarObjectGroupDiscovery } from "@/lib/sidebar/sidebarSearchDiscovery"; import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState"; import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts"; @@ -405,7 +406,7 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch } return; } - if (refreshedNodeIds && isSidebarSearchContainer(node) && !node.children?.length && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { + if (refreshedNodeIds && isSidebarSearchContainer(node) && needsSidebarObjectGroupDiscovery(node, searchableObjectGroupTypes) && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { scheduledNodeIds?.add(node.id); const wasCollapsed = node.isExpanded !== true; searchExpansionState.markFiltered(node.id, wasCollapsed); diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts index 31f054abd4..e26200e691 100644 --- a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts @@ -7,7 +7,11 @@ const source = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), " describe("ConnectionTree global search loading", () => { it("discovers collapsed database and schema containers before searching object groups", () => { expect(source).toContain("function isSidebarSearchContainer(node: TreeNode)"); - expect(source).toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/); + // A collapsed database already holds the saved-SQL root, so it has children + // even when its object groups were never fetched; the search has to look for + // a searchable group instead of an empty child list. + expect(source).toMatch(/isSidebarSearchContainer\(node\) && needsSidebarObjectGroupDiscovery\(node, searchableObjectGroupTypes\)/); + expect(source).not.toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/); expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })"); expect(source).toContain("searchExpansionState.markFiltered(node.id, wasCollapsed)"); expect(source).toMatch(/if \(refreshedNodeIds && node\.children\) \{[\s\S]*?searchableObjectGroupTypes\.has\(child\.type\)/); diff --git a/apps/desktop/src/lib/sidebar/__tests__/sidebarSearchDiscovery.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/sidebarSearchDiscovery.spec.ts new file mode 100644 index 0000000000..2efb23313f --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/sidebarSearchDiscovery.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { TreeNode, TreeNodeType } from "@/types/database"; +import { needsSidebarObjectGroupDiscovery } from "@/lib/sidebar/sidebarSearchDiscovery"; + +const objectGroupTypes: ReadonlySet = new Set(["group-tables", "group-views"]); + +function node(partial: Partial & Pick): TreeNode { + return { label: partial.id, ...partial }; +} + +describe("needsSidebarObjectGroupDiscovery", () => { + it("asks for discovery while a container only holds its saved SQL root", () => { + // Reproduces #9174: the database node already has a child (the saved SQL + // root) before its object groups are fetched, so an empty-child check would + // skip it and its tables would never join the search. + const database = node({ + id: "mysql/dbx", + type: "database", + connectionId: "c1", + database: "dbx", + children: [node({ id: "saved-sql-root", type: "saved-sql-root", connectionId: "c1", database: "dbx" })], + }); + expect(needsSidebarObjectGroupDiscovery(database, objectGroupTypes)).toBe(true); + }); + + it("asks for discovery when the container has no children at all", () => { + const schema = node({ id: "pg/public", type: "schema", connectionId: "c1", database: "postgres" }); + expect(needsSidebarObjectGroupDiscovery(schema, objectGroupTypes)).toBe(true); + expect(needsSidebarObjectGroupDiscovery({ ...schema, children: [] }, objectGroupTypes)).toBe(true); + }); + + it("stops asking once a searchable object group is loaded", () => { + const database = node({ + id: "mysql/dbx", + type: "database", + connectionId: "c1", + database: "dbx", + children: [node({ id: "saved-sql-root", type: "saved-sql-root", connectionId: "c1", database: "dbx" }), node({ id: "group-views", type: "group-views", connectionId: "c1", database: "dbx" })], + }); + expect(needsSidebarObjectGroupDiscovery(database, objectGroupTypes)).toBe(false); + }); + + it("ignores non-group children such as tables already listed under the container", () => { + const database = node({ + id: "mysql/dbx", + type: "database", + connectionId: "c1", + database: "dbx", + children: [node({ id: "dbx.cells_probe", type: "table", connectionId: "c1", database: "dbx" }), node({ id: "user-admin", type: "user-admin" })], + }); + expect(needsSidebarObjectGroupDiscovery(database, objectGroupTypes)).toBe(true); + }); + + it("ignores object groups owned by another connection", () => { + const database = node({ + id: "mysql/dbx", + type: "database", + connectionId: "c1", + database: "dbx", + children: [node({ id: "group-tables", type: "group-tables" })], + }); + expect(needsSidebarObjectGroupDiscovery(database, objectGroupTypes)).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchDiscovery.ts b/apps/desktop/src/lib/sidebar/sidebarSearchDiscovery.ts new file mode 100644 index 0000000000..4d0f3ee980 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarSearchDiscovery.ts @@ -0,0 +1,16 @@ +import type { TreeNode, TreeNodeType } from "@/types/database"; + +/** + * Whether a sidebar search still has to fetch a container's on-demand object + * groups before it can look for matches below it. + * + * A collapsed container is not necessarily childless: every database keeps its + * saved-SQL root (`tree.queries`) as a child even before the object groups are + * fetched, so "no children" is not a reliable discovery signal. Checking for a + * searchable group instead keeps databases whose groups were never loaded out + * of the search, which made a table that exists in several databases appear + * under only one of them. + */ +export function needsSidebarObjectGroupDiscovery(node: TreeNode, objectGroupTypes: ReadonlySet): boolean { + return !node.children?.some((child) => child.connectionId != null && objectGroupTypes.has(child.type)); +}