feat(sidebar): pin table search below the database header

This commit is contained in:
二丫讲梵
2026-10-10 20:44:40 +08:00
committed by GitHub
parent 8786c76ff4
commit 56ea5714af
3 changed files with 267 additions and 8 deletions
@@ -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<HTMLInputElement>("[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<HTMLInputElement>("[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<FlatTreeNode | null>(() => 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<CSSProperties>(() => {
const currentIndex = stickyContainerIndex.value;
if (currentIndex < 0) return {};
@@ -1173,14 +1240,14 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
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)"
/>
<TreeItem v-if="stickyTableSearchNode" :key="stickyTableSearchNode.renderKey" :node="stickyTableSearchNode.node" :depth="stickyTableSearchNode.depth" :reorder-disabled="true" :reference-drag-disabled="true" />
</div>
<div
v-if="hasSidebarVerticalOverflow"
@@ -2761,6 +2829,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:comment-label-width="sidebarCommentLabelWidths.get(stickyNode.node.id)"
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
/>
<TreeItem v-if="stickyTableSearchNode" :key="stickyTableSearchNode.renderKey" :node="stickyTableSearchNode.node" :depth="stickyTableSearchNode.depth" :reorder-disabled="true" :reference-drag-disabled="true" />
</div>
<div
v-if="hasSidebarVerticalOverflow"
@@ -2901,6 +2970,8 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
<style scoped>
.sticky-database-header {
background-color: var(--sidebar);
container-type: inline-size;
overflow: clip;
}
.connection-tree-scroller {
@@ -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)`);
});
}
});
@@ -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);
});
});