mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sidebar): pin table search below the database header
This commit is contained in:
@@ -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 {
|
||||
|
||||
+78
@@ -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)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
+110
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user