diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index a5dafc34e4..341c7eab23 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -649,11 +649,38 @@ function isCurrentTableSearchInteraction(focusRestore: TableSearchFocusRestore): } function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) { - void nextTick(() => { + void nextTick(async () => { if (!isCurrentTableSearchInteraction(focusRestore) || !focusRestore.shouldRestoreFocus) return; const root = rootRef.value; if (!root) return; - const input = Array.from(root.querySelectorAll("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === focusRestore.parentNodeId); + const active = document.activeElement; + if (active !== document.body && !(active instanceof HTMLInputElement && active.dataset.sidebarTableSearchParentId === focusRestore.parentNodeId)) return; + + // Filtering can move the old scroll position into another database/schema. + // Reveal the edited scope before looking for its input: the original row + // may not even be mounted in the virtual renderer at the old position. + const pinnedSearchIsVisible = stickyTableSearchNode.value?.node.tableSearchParentId === focusRestore.parentNodeId && !stickyHeaderStyle.value.transform; + const searchIndex = flatNodes.value.findIndex(({ node }) => isSidebarTableSearchControlNode(node) && node.tableSearchParentId === focusRestore.parentNodeId); + if (searchIndex < 0) return; + const scroller = currentTreeScroller(); + if (!pinnedSearchIsVisible && scroller) { + scroller.scrollTop = scrollTopForSidebarNode({ + index: searchIndex, + currentScrollTop: scroller.scrollTop, + viewportHeight: scroller.clientHeight, + scrollHeight: scroller.scrollHeight, + rowHeight: sidebarTreeRowHeight.value, + topOcclusionHeight: sidebarTreeRowHeight.value, + align: "nearest", + }); + updateSidebarScrollMetrics(); + if (useVirtualTree.value) treeScrollerRef.value?.updateVisibleItems(true); + await nextTick(); + if (!isCurrentTableSearchInteraction(focusRestore)) return; + } + // Prefer the pinned copy when both the original row and overlay exist. + const inputs = Array.from(root.querySelectorAll("[data-sidebar-table-search-parent-id]")).filter((item) => item.dataset.sidebarTableSearchParentId === focusRestore.parentNodeId); + const input = inputs.find((item) => item.closest(".sticky-database-header")) ?? inputs[0]; if (!input) return; // Keep the browser's current selection when the tree update preserved the @@ -1163,6 +1190,46 @@ const stickyContainerIndex = computed(() => { const stickyNode = computed(() => flatNodes.value[stickyContainerIndex.value] ?? null); +// Search controls belong to their parent subtree, including schema and table +// group scopes. Build the lookup once per tree change, not on every scroll. +type StickyTableSearchScope = { item: FlatTreeNode; endIndex: number }; +function buildTableSearchScopeIndex(nodes: readonly FlatTreeNode[]): (StickyTableSearchScope | undefined)[] { + const stack: { depth: number; scope: StickyTableSearchScope }[] = []; + const byIndex: (StickyTableSearchScope | undefined)[] = []; + for (let index = 0; index < nodes.length; index += 1) { + const item = nodes[index]; + while (stack.length && item.depth <= stack[stack.length - 1].depth) { + stack.pop()!.scope.endIndex = index; + } + if (isSidebarTableSearchControlNode(item.node)) { + const scope = { item, endIndex: nodes.length }; + stack.push({ depth: item.depth - 1, scope }); + } + byIndex.push(stack[stack.length - 1]?.scope); + } + return byIndex; +} +const tableSearchScopeByIndex = computed(() => buildTableSearchScopeIndex(flatNodes.value)); + +const stickyTableSearchScope = computed(() => { + const containerIndex = stickyContainerIndex.value; + if (containerIndex < 0) return null; + // The search row pins as it reaches the bottom of the database header. + const topIndex = Math.min(Math.floor(stickyScrollTop.value / sidebarTreeRowHeight.value), flatNodes.value.length - 1); + const nextNode = flatNodes.value[topIndex + 1]; + const index = nextNode && isSidebarTableSearchControlNode(nextNode.node) ? topIndex + 1 : topIndex; + const scope = tableSearchScopeByIndex.value[index]; + if (!scope || flatTreeIndex.value.stickyContainerIndexByIndex[index] !== containerIndex) return null; + // A sibling group/schema ends the search scope, not the database header. + // Remove only the search row before it would cover that sibling. Database + // and connection boundaries still push the complete overlay out below. + const scopeEndsInSameContainer = flatTreeIndex.value.stickyContainerIndexByIndex[scope.endIndex] === containerIndex; + if (scopeEndsInSameContainer && scope.endIndex * sidebarTreeRowHeight.value - stickyScrollTop.value <= 2 * sidebarTreeRowHeight.value) return null; + return scope; +}); +const stickyTableSearchNode = computed(() => stickyTableSearchScope.value?.item ?? null); +const stickyHeaderHeight = computed(() => sidebarTreeRowHeight.value * (stickyTableSearchNode.value ? 2 : 1)); + const stickyHeaderStyle = computed(() => { const currentIndex = stickyContainerIndex.value; if (currentIndex < 0) return {}; @@ -1173,14 +1240,14 @@ const stickyHeaderStyle = computed(() => { const nextCollisionIndex = nextContainerIndex < 0 ? nextBoundaryIndex : nextBoundaryIndex < 0 ? nextContainerIndex : Math.min(nextContainerIndex, nextBoundaryIndex); if (nextCollisionIndex < 0) return {}; const distanceToNext = nextCollisionIndex * sidebarTreeRowHeight.value - stickyScrollTop.value; - if (distanceToNext >= sidebarTreeRowHeight.value) return {}; + if (distanceToNext >= stickyHeaderHeight.value) return {}; return { - transform: `translateY(${Math.min(0, distanceToNext - sidebarTreeRowHeight.value)}px)`, + transform: `translateY(${Math.min(0, distanceToNext - stickyHeaderHeight.value)}px)`, }; }); -// Reset tracking when the tree rebuilds (connect/disconnect/collapse) so a -// stale scrollTop doesn't keep the overlay mounted after a structural change. +// Clamp tracking when the tree rebuilds, preserving the search input in the +// overlay when its results change instead of unmounting it on each keystroke. watch(flatNodes, (nodes, previousNodes) => { // The diagnostic events walk the whole expanded tree; only do that work when // the layout monitor is actually armed (dev default, disabled in production). @@ -1211,7 +1278,7 @@ watch(flatNodes, (nodes, previousNodes) => { sidebarContextMenuTarget.value = null; } } - stickyScrollTop.value = 0; + stickyScrollTop.value = Math.min(stickyScrollTop.value, Math.max(0, nodes.length * sidebarTreeRowHeight.value - sidebarScrollMetrics.value.clientHeight)); void nextTick(scheduleSidebarScrollMetricsUpdate); // After a structural change (list grew/shrunk, e.g. a Dameng connection // expands or collapses) or a same-length search projection replacement, @@ -1506,7 +1573,7 @@ async function flashSidebarNode(nodeId: string) { function topOcclusionHeightForSidebarNode(nodeId: string): number { const sticky = stickyNode.value; if (!sticky || sticky.id === nodeId) return 0; - return sidebarTreeRowHeight.value; + return stickyHeaderHeight.value; } /** Select and reveal a freshly created table group. */ @@ -2713,6 +2780,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid :comment-label-width="sidebarCommentLabelWidths.get(stickyNode.node.id)" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" /> +
+
.sticky-database-header { background-color: var(--sidebar); + container-type: inline-size; + overflow: clip; } .connection-tree-scroller { diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.stickySearchBoundary.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.stickySearchBoundary.spec.ts new file mode 100644 index 0000000000..f25dfe3e1a --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.stickySearchBoundary.spec.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { parse } from "vue/compiler-sfc"; +import { computed, ref } from "vue"; +import { describe, expect, it } from "vitest"; +import { createFlatTreeIndex, flattenTree } from "@/composables/useFlatTree"; +import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; +import type { TreeNode, TreeNodeType } from "@/types/database"; + +const source = parse(readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8")).descriptor.scriptSetup!.content; +const script = ts.createSourceFile("ConnectionTree.ts", source, ts.ScriptTarget.Latest, true); +const names = new Set(["stickyContainerIndex", "stickyNode", "buildTableSearchScopeIndex", "tableSearchScopeByIndex", "stickyTableSearchScope", "stickyTableSearchNode", "stickyHeaderHeight", "stickyHeaderStyle"]); +const statements = script.statements.filter((statement) => { + if (ts.isFunctionDeclaration(statement)) return names.has(statement.name?.text ?? ""); + return ts.isVariableStatement(statement) && statement.declarationList.declarations.some((declaration) => names.has(declaration.name.getText(script))); +}); +const compiled = ts.transpileModule(statements.map((statement) => statement.getText(script)).join("\n"), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + +function node(id: string, type: TreeNodeType, children?: TreeNode[]): TreeNode { + return { id, label: id, type, isExpanded: !!children, children }; +} +function tables() { + return Array.from({ length: 30 }, (_, index) => node(`table${index}`, "table")); +} +function fixture(tree: TreeNode[], grouped: boolean, rowHeight: number) { + const flatNodes = ref(insertSidebarTableSearchControls(flattenTree(tree), { enabled: true, sidebarObjectDisplay: grouped ? "grouped" : "simple", activeQueries: {} })); + const flatTreeIndex = computed(() => + createFlatTreeIndex(flatNodes.value, { + isSelectable: (item) => !isSidebarTableSearchControlNode(item), + isBoundary: (type) => type === "connection" || type === "connection-group", + isDatabaseContainer: (type) => type === "database", + isSchemaContainer: (type) => type === "schema", + }), + ); + const stickyScrollTop = ref(0); + const bindings = { computed, flatNodes, flatTreeIndex, stickyScrollTop, sidebarTreeRowHeight: ref(rowHeight), isTreeSearchFiltering: ref(false), isSidebarTableSearchControlNode, SCHEMA_LEVEL_TYPES: new Set(["schema"]) }; + const state = new Function(...Object.keys(bindings), `${compiled}; return {stickyNode, stickyTableSearchNode, stickyHeaderStyle, stickyHeaderHeight};`)(...Object.values(bindings)); + return { + state, + scrollBefore(id: string, distance: number) { + stickyScrollTop.value = flatNodes.value.findIndex((item) => item.id === id) * rowHeight - distance; + }, + }; +} + +describe("sticky table search scope boundaries", () => { + for (const rowHeight of [24, 28]) { + it.each(["grouped", "schemas"])(`keeps the database title pinned across %s (row height=${rowHeight})`, (mode) => { + const grouped = mode === "grouped"; + const first = node("first", grouped ? "group-tables" : "schema", tables()); + const second = node("second", grouped ? "group-views" : "schema", [node("view", "view")]); + const { state, scrollBefore } = fixture([node("conn", "connection", [node("db", "database", [first, second])])], grouped, rowHeight); + scrollBefore("second", 3 * rowHeight); + expect(state.stickyTableSearchNode.value.node.tableSearchParentId).toBe("first"); + for (const distance of [2 * rowHeight, 1.5 * rowHeight, 0.5 * rowHeight, 0]) { + scrollBefore("second", distance); + expect(state.stickyNode.value.id).toBe("db"); + expect(state.stickyHeaderStyle.value.transform).toBeUndefined(); + if (distance > 0 || grouped) { + expect(state.stickyTableSearchNode.value).toBeNull(); + expect(state.stickyHeaderHeight.value).toBe(rowHeight); + } + } + if (!grouped) expect(state.stickyTableSearchNode.value.node.tableSearchParentId).toBe("second"); + }); + + it.each(["database", "connection", "schema"])(`still pushes the entire overlay at a %s boundary (row height=${rowHeight})`, (boundary) => { + const first = node("first", boundary === "schema" ? "schema" : "database", tables()); + const second = node("second", boundary as TreeNodeType, [node("view", "view")]); + const tree = boundary === "connection" ? [node("conn", "connection", [first]), second] : [node("conn", "connection", [first, second])]; + const { state, scrollBefore } = fixture(tree, false, rowHeight); + scrollBefore("second", rowHeight / 2); + expect(state.stickyNode.value.id).toBe("first"); + expect(state.stickyTableSearchNode.value.node.tableSearchParentId).toBe("first"); + expect(state.stickyHeaderStyle.value.transform).toBe(`translateY(${-1.5 * rowHeight}px)`); + }); + } +}); diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.tableSearchFocus.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.tableSearchFocus.spec.ts new file mode 100644 index 0000000000..44ae3646f7 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.tableSearchFocus.spec.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { parse } from "vue/compiler-sfc"; +import { nextTick } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { scrollTopForSidebarNode } from "@/lib/sidebar/sidebarActiveTabTarget"; +import { isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; + +const source = parse(readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8")).descriptor.scriptSetup!.content; +const script = ts.createSourceFile("ConnectionTree.ts", source, ts.ScriptTarget.Latest, true); +const restoreSource = script.statements.find((statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === "restoreTableSearchInput")!.getText(script); +const compiled = ts.transpileModule(restoreSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + +function fixture(virtual: boolean, rowHeight = 28) { + const root = document.createElement("div"); + document.body.append(root); + const input = document.createElement("input"); + input.dataset.sidebarTableSearchParentId = "A"; + input.value = "orders"; + const scroller = { scrollTop: 500 * rowHeight, clientHeight: 20 * rowHeight, scrollHeight: 1007 * rowHeight }; + const refresh = vi.fn(() => root.append(input)); + if (!virtual) root.append(input); + const pinned = { value: { node: { tableSearchParentId: "B" } } }; + const current = { value: true }; + const nodes = { value: [{ node: { type: "connection" } }, { node: { type: "database", id: "A" } }, { node: { type: "table-search-control", tableSearchParentId: "A" } }] }; + const bindings = { + nextTick, + document, + HTMLInputElement, + isCurrentTableSearchInteraction: () => current.value, + rootRef: { value: root }, + stickyTableSearchNode: pinned, + stickyHeaderStyle: { value: {} }, + flatNodes: nodes, + isSidebarTableSearchControlNode, + currentTreeScroller: () => scroller, + scrollTopForSidebarNode, + sidebarTreeRowHeight: { value: rowHeight }, + updateSidebarScrollMetrics: vi.fn(), + useVirtualTree: { value: virtual }, + treeScrollerRef: { value: { updateVisibleItems: refresh } }, + }; + const restore = new Function(...Object.keys(bindings), `${compiled}; return restoreTableSearchInput;`)(...Object.values(bindings)); + const run = async () => { + restore({ parentNodeId: "A", shouldRestoreFocus: true, selection: { start: 2, end: 4, direction: "forward" } }); + await nextTick(); + await nextTick(); + }; + return { root, input, scroller, refresh, pinned, nodes, current, run }; +} + +afterEach(() => document.body.replaceChildren()); + +describe("table search focus after results shrink into a later database", () => { + for (const virtual of [false, true]) { + it.each([24, 28])(`reveals A and restores the caret (virtual=${virtual}, row height=%i)`, async (rowHeight) => { + const { input, scroller, refresh, run } = fixture(virtual, rowHeight); + await run(); + expect(scroller.scrollTop).toBe(rowHeight); + expect(document.activeElement).toBe(input); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 4]); + expect(refresh).toHaveBeenCalledTimes(virtual ? 1 : 0); + }); + } + + it("keeps the current scroll position and selection when A is still pinned", async () => { + const { root, input, pinned, scroller, run } = fixture(false); + const overlay = document.createElement("div"); + overlay.className = "sticky-database-header"; + root.append(overlay); + overlay.append(input); + pinned.value.node.tableSearchParentId = "A"; + input.focus(); + input.setSelectionRange(1, 1); + await run(); + expect(scroller.scrollTop).toBe(14000); + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(1); + }); + + it("does not scroll or steal focus after the user selects another control", async () => { + const { scroller, refresh, run } = fixture(true); + const button = document.createElement("button"); + document.body.append(button); + button.focus(); + await run(); + expect(scroller.scrollTop).toBe(14000); + expect(refresh).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(button); + }); + + it("ignores an obsolete search response", async () => { + const { current, scroller, refresh, run } = fixture(true); + current.value = false; + await run(); + expect(scroller.scrollTop).toBe(14000); + expect(refresh).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(document.body); + }); + + it("does not restore a search control removed by collapse or disabling search", async () => { + const { nodes, scroller, refresh, run } = fixture(true); + nodes.value = []; + await run(); + expect(scroller.scrollTop).toBe(14000); + expect(refresh).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(document.body); + }); +});