perf(sidebar): reduce tree selection overhead
This commit is contained in:
parent
b4a207fc6b
commit
a2c9503a3d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue