fix(sidebar): keep context menu open during refresh

This commit is contained in:
t8y2 committed 2026-07-27 16:06:34 +08:00
1 parent 267310ced1
commit 1ad3354c46
4 files changed
+35 -9

No files matched your search

@@ -35,7 +35,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { createSidebarActionTarget, findSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
@@ -611,12 +611,16 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
// Reset tracking when the tree rebuilds (connect/disconnect/collapse) so a
// stale scrollTop doesn't keep the overlay mounted after a structural change.
watch(flatNodes, () => {
// Menu actions originate from a rendered row instance. Close the singleton
// before a structural update can recycle that row onto another node.
sidebarContextMenuRef.value?.close();
sidebarContextMenuItems.value = [];
sidebarContextMenuTarget.value = null;
watch(flatNodes, (nodes) => {
const contextMenuTarget = sidebarContextMenuTarget.value;
if (contextMenuTarget) {
const visibleContextMenuTarget = nodes.find(({ node }) => matchesSidebarActionTarget(node, contextMenuTarget))?.node;
if (!visibleContextMenuTarget || visibleContextMenuTarget.valid === false) {
sidebarContextMenuRef.value?.close();
sidebarContextMenuItems.value = [];
sidebarContextMenuTarget.value = null;
}
}
stickyScrollTop.value = 0;
void nextTick(scheduleSidebarScrollMetricsUpdate);
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { createSidebarActionRequest, createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { createSidebarActionRequest, createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActionTarget, releaseRemovedSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
function tableNode(): TreeNode {
return {
@@ -38,6 +38,19 @@ describe("sidebar action targets", () => {
expect(Object.isFrozen(request)).toBe(true);
});
it("matches a live node with the same action target identity", () => {
const node = tableNode();
expect(matchesSidebarActionTarget(node, createSidebarActionTarget(node))).toBe(true);
});
it("does not match the same id after identity fields change", () => {
const target = createSidebarActionTarget(tableNode());
const changedNode = { ...tableNode(), connectionId: "connection-2", label: "accounts" };
expect(matchesSidebarActionTarget(changedNode, target)).toBe(false);
});
it("does not resolve a recycled row with the same local id", () => {
const target = createSidebarActionTarget(tableNode());
const recycled = tableNode();
@@ -12,13 +12,17 @@ function sameActionTarget(left: TreeNode, right: SidebarActionTarget): boolean {
return left.id === right.id && left.type === right.type && left.connectionId === right.connectionId && left.database === right.database && left.schema === right.schema && left.catalog === right.catalog && left.label === right.label && left.signature === right.signature;
}
export function matchesSidebarActionTarget(node: TreeNode, target: SidebarActionTarget): boolean {
return sameActionTarget(node, target);
}
export function findSidebarActionTarget(nodes: readonly TreeNode[], target: SidebarActionTarget): TreeNode | null {
const visited = new WeakSet<TreeNode>();
const find = (items: readonly TreeNode[]): TreeNode | null => {
for (const node of items) {
if (visited.has(node)) continue;
visited.add(node);
if (sameActionTarget(node, target)) return node;
if (matchesSidebarActionTarget(node, target)) return node;
const child = node.children ? find(node.children) : null;
if (child) return child;
const hiddenChild = node.hiddenChildren ? find(node.hiddenChildren) : null;