diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts index b3f0c26892..a8cad964fa 100644 --- a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts @@ -4,6 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import TreeItem from "@/components/sidebar/TreeItem.vue"; +import { filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; import type { TreeNode } from "@/types/database"; @@ -11,9 +12,12 @@ const connectionStore = { activeConnectionId: "connection-1", connectedIds: new Set(["connection-1"]), connectingIds: new Set(), + connectionErrors: {}, connectionMultiSelectActive: false, connections: [], getConfig: () => ({ id: "connection-1", db_type: "sqlserver" }), + getSidebarVisibleFilterSummary: () => null, + clearConnectionError: vi.fn(), isDefaultDatabase: () => false, isDefaultSchema: () => false, isPinnedTreeNodeReorderTarget: () => false, @@ -182,3 +186,58 @@ describe("TreeItem load-more activation", () => { expect(unknown.row.querySelector('[data-invalid-object-indicator="true"]')).toBeNull(); }); }); + +describe("TreeItem searched connection activation", () => { + function searchedConnection(): TreeNode { + const filtered = filterSidebarTree( + [ + { + id: "connection-1", + label: "1000", + type: "connection", + connectionId: "connection-1", + isExpanded: false, + children: [ + { + id: "connection-1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "connection-1", + database: "", + }, + ], + }, + ], + "1000", + new Set(), + )[0]; + if (!filtered) throw new Error("Expected the matching connection search result"); + return filtered; + } + + it("activates a connection search result on one click in single-click mode", async () => { + settingsStore.editorSettings.sidebarActivation = "single"; + const node = searchedConnection(); + const { row, host } = await mountTreeItem(node); + + row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + + expect(node.children).toEqual([]); + expect(node.isExpanded).toBe(false); + expect(host.handleRowClick).toHaveBeenCalledWith(node, 1); + expect(host.handleRowDoubleClick).not.toHaveBeenCalled(); + }); + + it("waits for a double click before activating a connection search result in double-click mode", async () => { + settingsStore.editorSettings.sidebarActivation = "double"; + const node = searchedConnection(); + const { row, host } = await mountTreeItem(node); + + row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + expect(host.handleRowClick).not.toHaveBeenCalled(); + + row.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 })); + + expect(host.handleRowDoubleClick).toHaveBeenCalledWith(node, expect.any(MouseEvent)); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index e1d3d1bd85..679cbc192d 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -2,6 +2,7 @@ import type { TreeNode, TreeNodeType } from "@/types/database"; import { createSidebarLabelMatcher, type SidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch"; const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]); +const hiddenSearchNodeTypes = new Set(["user-admin", "dameng-job-admin"]); function bestMatch(matchLabel: SidebarLabelMatcher, label: string, comment?: string | null) { const lm = matchLabel(label); @@ -43,10 +44,16 @@ function applySearchCollapsedState(node: TreeNode, collapsedIds: ReadonlySet): TreeNode[] | undefined { + if (!node.children) return undefined; + return node.children.filter((child) => !hiddenSearchNodeTypes.has(child.type)).map((child) => applySearchCollapsedState(child, collapsedIds)); +} + function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet, searchableNodeTypes?: ReadonlySet): TreeNode[] { const filteredNodes: { node: TreeNode; score: number }[] = []; for (const node of nodes) { + if (matchLabel && hiddenSearchNodeTypes.has(node.type)) continue; if (node.type === "object-browser" && node.hiddenChildren) { const matches = node.hiddenChildren.flatMap((child) => { if (searchableNodeTypes && !searchableNodeTypes.has(child.type)) return []; @@ -67,7 +74,10 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe // A type-matched table keeps its loaded detail groups after the text query // is cleared instead of being rebuilt with an empty filtered child list. const preservesTypeMatchedTable = !matchLabel && !!selfMatch && node.type === "table"; - const filteredChildren = preservesSubtree ? node.children?.map((child) => applySearchCollapsedState(child, collapsedIds)) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined; + // Connection utility entries are synthetic navigation actions, not schema + // search results. Keep real loaded descendants for connection-name matches, + // but do not let those actions make a disconnected result look expanded. + const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined; if (selfMatch || (filteredChildren && filteredChildren.length > 0)) { if (!node.children || preservesTypeMatchedTable) { diff --git a/packages/app-tests/sidebarSearchTree.test.ts b/packages/app-tests/sidebarSearchTree.test.ts index c4ef771f28..07ecfb9571 100644 --- a/packages/app-tests/sidebarSearchTree.test.ts +++ b/packages/app-tests/sidebarSearchTree.test.ts @@ -212,6 +212,89 @@ test("preserves loaded children when the connection itself matches search", () = assert.equal(filtered[0]?.children?.[0]?.children?.[0]?.label, "products"); }); +test("omits synthetic connection management entries when the connection itself matches search", () => { + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "1000-test", + type: "connection", + connectionId: "conn:1", + isExpanded: false, + children: [ + { + id: "conn:1:inventory", + label: "inventory", + type: "database", + connectionId: "conn:1", + database: "inventory", + }, + { + id: "conn:1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "conn:1", + database: "", + }, + { + id: "conn:1:__dameng_jobs", + label: "tree.damengJobAdmin", + type: "dameng-job-admin", + connectionId: "conn:1", + database: "", + }, + ], + }, + ]; + + const filtered = filterSidebarTree(nodes, "1000", new Set()); + + assert.deepEqual( + filtered[0]?.children?.map((child) => child.type), + ["database"], + ); + assert.equal(filtered[0]?.isExpanded, true); +}); + +test("keeps a disconnected connection search result collapsed when it only has synthetic management entries", () => { + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "1000", + type: "connection", + connectionId: "conn:1", + isExpanded: false, + children: [ + { + id: "conn:1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "conn:1", + database: "", + }, + ], + }, + ]; + + const filtered = filterSidebarTree(nodes, "1000", new Set()); + + assert.deepEqual(filtered[0]?.children, []); + assert.equal(filtered[0]?.isExpanded, false); +}); + +test("does not return synthetic connection management entries as direct text matches", () => { + const nodes: TreeNode[] = [ + { + id: "conn:1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "conn:1", + database: "", + }, + ]; + + assert.deepEqual(filterSidebarTree(nodes, "userAdmin", new Set()), []); +}); + test("temporarily collapses an empty object group within a preserved search subtree", () => { const tablesGroup: TreeNode = { id: "conn:1:inventory:__tables",