perf(sidebar): reduce tree selection overhead

This commit is contained in:
t8y2 2026-06-05 14:30:25 +08:00
parent b4a207fc6b
commit a2c9503a3d
5 changed files with 56 additions and 10 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, type Component } from "vue";
import { ref, computed, nextTick, watch, provide, type Component } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
@ -26,6 +26,7 @@ import {
shouldVirtualizeFlatTree,
type FlatTreeNode,
} from "@/composables/useFlatTree";
import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext";
import TreeItem from "./TreeItem.vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
@ -148,6 +149,11 @@ const filteredNodes = computed(() => {
const flatNodes = computed<FlatTreeNode[]>(() => flattenTree(filteredNodes.value));
const visibleNodes = computed<TreeNode[]>(() => flatNodes.value.map((item) => item.node));
const visibleNodeIndexById = computed(() => {
const next = new Map<string, number>();
visibleNodes.value.forEach((node, index) => next.set(node.id, index));
return next;
});
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const sidebarTreeOverflowClass = computed(() =>
@ -156,6 +162,11 @@ const sidebarTreeOverflowClass = computed(() =>
: "overflow-x-hidden",
);
provide(sidebarTreeContextKey, {
getVisibleNodes: () => visibleNodes.value,
getVisibleNodeIndex: (id: string) => visibleNodeIndexById.value.get(id) ?? -1,
});
const pendingRenameGroupId = ref<string | null>(null);
const highlightedNodeId = ref<string | null>(null);
let highlightTimer: number | undefined;
@ -495,7 +506,6 @@ defineExpose({ focusSearch, createNewGroup });
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.node.id"
:visible-nodes="visibleNodes"
@node-toggled="onNodeToggled"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
@ -516,7 +526,6 @@ defineExpose({ focusSearch, createNewGroup });
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
:visible-nodes="visibleNodes"
@node-toggled="onNodeToggled"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onBeforeUnmount } from "vue";
import { ref, computed, nextTick, watch, onBeforeUnmount, inject } from "vue";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
@ -127,8 +127,10 @@ import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext";
import { sidebarDisplayTableName } from "@/lib/sidebarTableNameDisplay";
import {
selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes,
treeSelectionRangeIdsByIndex,
treeSelectionRangeIds,
} from "@/lib/sidebarTreeSelection";
import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
@ -174,7 +176,6 @@ const props = defineProps<{
dragDisabled?: boolean;
pendingRename?: boolean;
highlighted?: boolean;
visibleNodes?: TreeNode[];
}>();
const emit = defineEmits<{
@ -186,6 +187,7 @@ const emit = defineEmits<{
const usesFullWidthLabel = computed(() =>
usesFullWidthTreeLabel(props.node.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll),
);
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() =>
usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 flex-1 truncate",
@ -459,7 +461,7 @@ function runRowClickAction() {
}
function visibleTreeNodes(): TreeNode[] {
if (props.visibleNodes) return props.visibleNodes;
if (sidebarTreeContext) return sidebarTreeContext.getVisibleNodes();
return flattenTree(connectionStore.treeNodes).map((item) => item.node);
}
@ -485,10 +487,20 @@ function toggleTreeNodeSelection(node: TreeNode) {
function selectTreeNodeRange(node: TreeNode) {
const visible = visibleTreeNodes();
const anchorId = connectionStore.treeSelectionAnchorId || connectionStore.selectedTreeNodeId || node.id;
const currentIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(node.id) : -1;
const anchorIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(anchorId) : -1;
if (sidebarTreeContext && currentIndex >= 0 && anchorIndex >= 0) {
connectionStore.selectedTreeNodeIds = treeSelectionRangeIdsByIndex(visible, currentIndex, anchorIndex, node.id);
connectionStore.selectedTreeNodeId = node.id;
return;
}
if (!visible.some((item) => item.id === anchorId) || !visible.some((item) => item.id === node.id)) {
selectSingleTreeNode(node);
return;
}
const rangeIds = treeSelectionRangeIds(visible, node.id, anchorId, connectionStore.selectedTreeNodeId);
connectionStore.selectedTreeNodeIds = rangeIds;
connectionStore.selectedTreeNodeId = node.id;

View File

@ -0,0 +1,9 @@
import type { InjectionKey } from "vue";
import type { TreeNode } from "@/types/database";
export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
}
export const sidebarTreeContextKey: InjectionKey<SidebarTreeContext> = Symbol("sidebar-tree-context");

View File

@ -6,6 +6,18 @@ export function selectedTreeNodesInVisibleOrder(visibleNodes: TreeNode[], select
return visibleNodes.filter((node) => ids.has(node.id));
}
export function treeSelectionRangeIdsByIndex(
visibleNodes: TreeNode[],
currentIndex: number,
anchorIndex: number,
currentId?: string,
): string[] {
if (anchorIndex < 0 || currentIndex < 0) return currentId ? [currentId] : [];
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
return visibleNodes.slice(start, end + 1).map((node) => node.id);
}
export function treeSelectionRangeIds(
visibleNodes: TreeNode[],
currentId: string,
@ -15,8 +27,5 @@ export function treeSelectionRangeIds(
const anchor = anchorId || selectedId || currentId;
const anchorIndex = visibleNodes.findIndex((node) => node.id === anchor);
const currentIndex = visibleNodes.findIndex((node) => node.id === currentId);
if (anchorIndex < 0 || currentIndex < 0) return [currentId];
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
return visibleNodes.slice(start, end + 1).map((node) => node.id);
return treeSelectionRangeIdsByIndex(visibleNodes, currentIndex, anchorIndex, currentId);
}

View File

@ -2,6 +2,7 @@ import { strict as assert } from "node:assert";
import test from "node:test";
import {
selectedTreeNodesInVisibleOrder,
treeSelectionRangeIdsByIndex,
treeSelectionRangeIds,
} from "../../apps/desktop/src/lib/sidebarTreeSelection.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
@ -25,6 +26,12 @@ test("tree range selection falls back to the current node when the anchor is fil
assert.deepEqual(treeSelectionRangeIds(filtered, "customers", "orders"), ["customers"]);
});
test("tree range selection can reuse precomputed visible indexes", () => {
const filtered = [nodes[0], nodes[1], nodes[3]];
assert.deepEqual(treeSelectionRangeIdsByIndex(filtered, 2, 0, "customers"), ["orders", "order_lines", "customers"]);
});
test("selected tree nodes are ordered and limited by visible nodes", () => {
const filtered = [nodes[1], nodes[3]];