fix(sidebar): keep table search results on Enter

This commit is contained in:
jearhe authored and GitHub committed 2026-08-23 01:39:53 +08:00
1 parent acc2137cde
commit 0908a944e8
4 files changed
+77 -11

No files matched your search

@@ -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;
@@ -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<HTMLInputElement>("[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();
});
});
+10 -1
View File
@@ -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";
@@ -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;
}