From 8a385c3348f330abbcba09d418008c24c127629f Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 26 Sep 2026 12:43:47 +0000 Subject: [PATCH] fix(sidebar): preserve searched database activation Closes #10131 --- .../sidebar/SidebarTreeRuntimeHost.vue | 27 +++++-- .../SidebarTreeRuntimeHost.expansion.spec.ts | 79 ++++++++++++++++++- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index ecf189fc5c..bfbd0c4945 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1126,27 +1126,32 @@ async function toggle(requestId = beginNavigationRequest()) { } } -function runRowClickAction(clickDetail: number, requestId: number) { +function runRowClickAction(clickDetail: number) { const node = activeNode.value; if (node.type === "load-more") { if (clickDetail > 1) return; + beginNavigationRequest(); void loadMoreObjectGroupChildren(); return; } if (node.type === "object-browser") { if (clickDetail > 1) return; + beginNavigationRequest(); void openObjectBrowser(); return; } if (node.type === "mongo-gridfs") { + beginNavigationRequest(); openMongoTreeData(node); return; } if (node.type === "event") { + beginNavigationRequest(); void openObjectBrowser(false, true); return; } if (node.type === "group-events") { + beginNavigationRequest(); void openObjectBrowser(); return; } @@ -1155,13 +1160,17 @@ function runRowClickAction(clickDetail: number, requestId: number) { // between adjacent rows. Nacos entries are idempotent navigation targets, so // do not mistake that rapid one-click switching for a double-click toggle. if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node) || isRepeatableNavigationTreeNode(node.type))) return; + // A double click emits click(detail=2) before dblclick. Claim ownership only + // after that follow-up click has resolved to a real action, otherwise it + // makes the first click's pending connection/expansion request look stale. + const requestId = beginNavigationRequest(); if (action === "open-data") { scheduleOpenData(node); } else if (action === "open-object-browser") { void openObjectBrowser(false, false, true); } else if (action === "open-object-browser-and-expand") { void openObjectBrowser(false, false, true); - if (!node.isExpanded) void toggle(); + if (!node.isExpanded) void toggle(requestId); } else if (action === "open-source") { openObjectSourceDialog(false); } else if (action === "open-extension-details") { @@ -1551,21 +1560,27 @@ function requestDeleteSelectedNode(): boolean { function onDoubleClick(event: MouseEvent) { if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return; if (activeNode.value.type === "event") { + beginNavigationRequest(); void openObjectBrowser(false, true); return; } if (activeNode.value.type === "group-events") { + beginNavigationRequest(); void openObjectBrowser(); return; } const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType(), canOpenConnectionDatabaseBrowser.value, settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation); + // In single-click mode the trailing dblclick normally has no action and must + // leave the first click's request ownership intact. + if (action === "none") return; + const requestId = beginNavigationRequest(); if (action === "open-database-browser") { void openDatabaseBrowser(); } else if (action === "open-object-browser") { void openObjectBrowser(false, false, true); } else if (action === "open-object-browser-and-expand") { void openObjectBrowser(false, false, true); - if (!activeNode.value.isExpanded) void toggle(); + if (!activeNode.value.isExpanded) void toggle(requestId); } else if (action === "open-data") { openDataImmediately(activeNode.value); } else if (action === "activate-data") { @@ -1581,7 +1596,7 @@ function onDoubleClick(event: MouseEvent) { } else if (action === "toggle" && (activeNode.value.type === "mongo-gridfs" || isDocumentBrowserTreeNode(activeNode.value.type))) { openMongoTreeData(activeNode.value); } else if (action === "toggle") { - toggle(); + void toggle(requestId); } } @@ -6891,9 +6906,8 @@ function buildContextMenu(node: TreeNode): ContextMenuItem[] { } function handleRowClick(node: TreeNode, clickDetail: number) { - const requestId = beginNavigationRequest(); activateRuntimeNode(node); - runRowClickAction(clickDetail, requestId); + runRowClickAction(clickDetail); } function handleRowDoubleClick(node: TreeNode, event: MouseEvent) { @@ -6905,7 +6919,6 @@ function handleRowDoubleClick(node: TreeNode, event: MouseEvent) { activateRuntimeNode(node); return; } - beginNavigationRequest(); activateRuntimeNode(node); onDoubleClick(event); } diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts index ea5ec68578..fde905d353 100644 --- a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -6,6 +6,7 @@ import i18n from "@/i18n"; import type { TreeNode } from "@/types/database"; import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; +import { filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree"; import { OBJECT_BROWSER_SEARCH_FOCUS_EVENT } from "@/lib/tabs/objectBrowserSearchFocus"; import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue"; @@ -25,6 +26,7 @@ const connectionStore = { loadObjectGroupChildren: vi.fn(async (node: TreeNode) => { node.isExpanded = true; }), + loadTables: vi.fn(async () => undefined), loadXuguTablespaces: vi.fn(async (node: TreeNode) => { node.isExpanded = true; }), @@ -40,6 +42,8 @@ const queryStore = { openObjectBrowser: vi.fn(() => "object-browser-tab"), }; +const toast = vi.fn(); + const settingsStore = { editorSettings: { sidebarActivation: "single" as "single" | "double", @@ -49,14 +53,14 @@ const settingsStore = { }; vi.mock("@/stores/connectionStore", () => ({ - CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "connection attempt cancelled", + CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "Connection attempt was cancelled", useConnectionStore: () => connectionStore, })); vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => queryStore })); vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => settingsStore })); vi.mock("@/stores/savedSqlStore", () => ({ useSavedSqlStore: () => ({}) })); -vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast }) })); vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: vi.fn() }) })); vi.mock("@/composables/useSidebarDataOpenRuntime", () => ({ useSidebarDataOpenRuntime: () => ({ openData: vi.fn() }) })); vi.mock("@/composables/useDatabaseOptions", () => ({ useDatabaseOptions: () => ({ getDatabaseOptions: vi.fn() }) })); @@ -82,6 +86,7 @@ afterEach(() => { connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true); connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" }); connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: true, writable: true, writePermissions: null }); + connectionStore.loadTables.mockResolvedValue(undefined); }); describe("SidebarTreeRuntimeHost expansion", () => { @@ -151,6 +156,76 @@ describe("SidebarTreeRuntimeHost expansion", () => { expect(queryStore.setTableMeta).not.toHaveBeenCalled(); }); + it("keeps a searched database browse-and-expand activation current through the complete double-click sequence", async () => { + let resolveConnection!: () => void; + const connection = new Promise((resolve) => { + resolveConnection = resolve; + }); + connectionStore.ensureConnected.mockReturnValue(connection); + settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation = true; + + const database: TreeNode = { + id: "mysql:inventory", + label: "inventory", + type: "database", + connectionId: "mysql", + database: "inventory", + isExpanded: false, + children: [], + }; + const root: TreeNode = { + id: "mysql", + label: "local-mysql", + type: "connection", + connectionId: "mysql", + isExpanded: true, + children: [database], + }; + connectionStore.treeNodes = [root]; + const searchedDatabase = filterSidebarTree([root], "inventory", new Set())[0]?.children?.[0]; + expect(searchedDatabase).toBeDefined(); + expect(searchedDatabase).not.toBe(database); + + const cancellationHandled = vi.fn(); + const cancellation = { + get message() { + cancellationHandled(); + return "Connection attempt was cancelled"; + }, + }; + connectionStore.loadTables.mockImplementationOnce(async () => { + await connectionStore.ensureConnected("mysql"); + throw cancellation; + }); + + const host = ref | null>(null); + const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: searchedDatabase!, depth: 1 }) })); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + await nextTick(); + + // A browser double click emits click(1), click(2), then dblclick. Only the + // first event activates a database in single-click mode. + host.value!.handleRowClick(searchedDatabase!, 1); + await vi.waitFor(() => expect(connectionStore.ensureConnected).toHaveBeenCalledOnce()); + host.value!.handleRowClick(searchedDatabase!, 2); + host.value!.handleRowDoubleClick(searchedDatabase!, new MouseEvent("dblclick", { detail: 2 })); + + expect(connectionStore.loadTables).toHaveBeenCalledOnce(); + expect(connectionStore.ensureConnected).toHaveBeenCalledTimes(2); + + resolveConnection(); + // Reading the cancellation proves the original request still owns its + // completion; a stale request returns before classifying the failure. + await vi.waitFor(() => expect(cancellationHandled).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(queryStore.openObjectBrowser).toHaveBeenCalledOnce()); + expect(queryStore.openObjectBrowser).toHaveBeenCalledWith("mysql", "inventory", undefined, undefined, undefined, false, undefined, undefined); + expect(toast).not.toHaveBeenCalled(); + }); + it("activates the owning connection when a cached node toggles locally", async () => { const group: TreeNode = { id: "mysql:basic:__tables",