diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index eafe8f9f80..b53dd1a821 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -46,7 +46,7 @@ import { type SidebarNodeScrollAlign, } from "@/lib/sidebar/sidebarActiveTabTarget"; import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; -import { createFlatTreeIndex, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; +import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime"; import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry"; @@ -980,15 +980,15 @@ watch(flatNodes, (nodes, previousNodes) => { stickyScrollTop.value = 0; void nextTick(scheduleSidebarScrollMetricsUpdate); // After a structural change (list grew/shrunk, e.g. a Dameng connection - // expands or collapses) reconcile the virtual scroller with the browser's - // scroll position. The recycle pool is rebuilt from the live scrollTop, but - // scrollTop is only clamped on the next layout, so right after a shrink the - // pool can still target a window beyond the new content — the viewport then - // lands inside the end spacer and shows a blank region until the next scroll - // event. Reading scrollHeight forces that layout (applying the clamp), then - // one explicit pool refresh keeps the rendered window aligned. Only needed - // when the item count changed; reorders are already rebuilt by the library. - if (nodes.length === (previousNodes?.length ?? -1)) return; + // expands or collapses) or a same-length search projection replacement, + // reconcile the virtual scroller with the browser's scroll position. The + // recycle pool is rebuilt from the live scrollTop, but scrollTop is only + // clamped on the next layout, so right after a shrink the pool can still + // target a window beyond the new content — the viewport then lands inside + // the end spacer and shows a blank region until the next scroll event. + // Reading scrollHeight forces that layout (applying the clamp), then one + // explicit pool refresh keeps the rendered window aligned. + if (!flatTreeRowsChanged(nodes, previousNodes)) return; void nextTick(() => { const scroller = treeScrollerRef.value; if (!scroller || !useVirtualTree.value) return; diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.connectionRename.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.connectionRename.spec.ts index 383d8f0387..97a98b5ea9 100644 --- a/apps/desktop/src/components/sidebar/__tests__/TreeItem.connectionRename.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.connectionRename.spec.ts @@ -4,6 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { createI18n } from "vue-i18n"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import TreeItem from "@/components/sidebar/TreeItem.vue"; +import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database"; @@ -59,6 +60,7 @@ vi.mock("@/stores/settingsStore", () => ({ sidebarAllowHorizontalScroll: false, sidebarHiddenTablePrefixes: [], sidebarObjectInfoMode: "none", + sidebarTableSearchLocal: false, }, }), })); @@ -189,4 +191,50 @@ describe("TreeItem connection quick rename", () => { await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.stringContaining("disk full"), 5000)); }); + + it("keeps Enter scoped to the table-search input without triggering a tree row action", async () => { + const container = document.createElement("div"); + document.body.append(container); + let bubbledKeydowns = 0; + container.addEventListener("keydown", () => { + bubbledKeydowns += 1; + }); + const host = runtimeHost(); + const runtime = createSidebarTreeRuntime(); + runtime.bindHost(host); + const setTableSearchQuery = vi.fn(); + const node: TreeNode = { + id: "connection-1:app:__table_search", + label: "sidebar.searchTablesInCurrentScope", + type: "table-search-control", + connectionId: connection.id, + database: "app", + tableSearchParentId: "connection-1:app", + }; + const app = createApp( + defineComponent({ + setup: () => () => h(TreeItem, { node, depth: 2 }), + }), + ); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.provide(sidebarTreeContextKey, { + getVisibleNodes: () => [], + getVisibleNodeIndex: () => -1, + setTableSearchQuery, + }); + app.mount(container); + mountedApps.push(app); + await nextTick(); + + const input = container.querySelector("[data-sidebar-table-search-parent-id]"); + expect(input).toBeTruthy(); + const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + input!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(bubbledKeydowns).toBe(0); + expect(host.handleRowKeydown).not.toHaveBeenCalled(); + expect(setTableSearchQuery).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts b/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts index ee0555b369..2a97213a32 100644 --- a/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts +++ b/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createFlatTreeIndex, flattenTree, mutateFlatTreeExpansion, replaceFlatTreeChildren, type FlatTreeNode } from "@/composables/useFlatTree"; +import { createFlatTreeIndex, flatTreeRowsChanged, flattenTree, mutateFlatTreeExpansion, replaceFlatTreeChildren, type FlatTreeNode } from "@/composables/useFlatTree"; import type { TreeNode, TreeNodeType } from "@/types/database"; function item(id: string, type: TreeNodeType, depth: number, children?: TreeNode[]): FlatTreeNode { @@ -145,6 +145,15 @@ describe("flat-tree range mutations", () => { }); describe("flat-tree render identities", () => { + it("detects same-length table-search row replacements for #6951", () => { + const previous = [item("table-a", "table", 1), item("table-b", "table", 1)]; + const unchanged = previous.map((entry) => ({ ...entry })); + const next = [item("table-c", "table", 1), item("table-b", "table", 1)]; + + expect(flatTreeRowsChanged(unchanged, previous)).toBe(false); + expect(flatTreeRowsChanged(next, previous)).toBe(true); + }); + it("isolates equal business ids that belong to different lineages", () => { const collidingId = "connection:a:b"; const collidingSchemaId = "connection:a:b:c"; diff --git a/apps/desktop/src/composables/useFlatTree.ts b/apps/desktop/src/composables/useFlatTree.ts index 911028cb12..23af46ca10 100644 --- a/apps/desktop/src/composables/useFlatTree.ts +++ b/apps/desktop/src/composables/useFlatTree.ts @@ -98,6 +98,15 @@ export function mutateFlatTreeExpansion(nodes: readonly FlatTreeNode[], parentIn return replaceFlatTreeChildren(nodes, parentIndex, parent); } +/** Whether the visible row identities changed enough to refresh recycled views. */ +export function flatTreeRowsChanged(nodes: readonly FlatTreeNode[], previousNodes: readonly FlatTreeNode[] | undefined): boolean { + if (!previousNodes || nodes.length !== previousNodes.length) return true; + return nodes.some((item, index) => { + const previous = previousNodes[index]; + return !previous || item.renderKey !== previous.renderKey || item.poolType !== previous.poolType || item.depth !== previous.depth; + }); +} + export function shouldVirtualizeFlatTree(count: number): boolean { return count > 0; }