fix(sidebar): preserve searched database activation

Closes #10131
This commit is contained in:
t8y2
2026-09-26 12:45:30 +00:00
parent dcd16e0d0a
commit 8a385c3348
2 changed files with 97 additions and 9 deletions
@@ -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);
}
@@ -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<void>((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<InstanceType<typeof SidebarTreeRuntimeHost> | 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",