fix(sidebar): reveal expanded tree items

This commit is contained in:
t8y2 2026-05-18 16:35:09 +08:00
parent 28f4aec50d
commit ab87de4054
4 changed files with 83 additions and 1 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
import { useConnectionStore } from "@/stores/connectionStore";
@ -11,6 +11,7 @@ import {
SIDEBAR_TREE_PRERENDER_COUNT,
SIDEBAR_TREE_SCROLL_BUFFER,
flattenTree,
scrollTopForExpandedTreeNode,
shouldVirtualizeFlatTree,
type FlatTreeNode,
} from "@/composables/useFlatTree";
@ -32,6 +33,7 @@ const store = useConnectionStore();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const searchInputRef = ref<HTMLInputElement>();
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const selectedTypes = ref<string[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
let searchTimer: number | undefined;
@ -139,6 +141,28 @@ function onSearchToggle(node: TreeNode) {
searchCollapsedIds.value = next;
}
async function onNodeToggled(node: TreeNode, wasExpanded: boolean) {
if (wasExpanded || !node.isExpanded) return;
await nextTick();
const expandedIndex = flatNodes.value.findIndex((item) => item.id === node.id);
const insertedRowCount = flattenTree([node]).length - 1;
const scroller = treeScrollerRef.value?.$el as HTMLElement | undefined;
if (!scroller || expandedIndex < 0 || insertedRowCount <= 0) return;
const nextScrollTop = scrollTopForExpandedTreeNode({
expandedIndex,
insertedRowCount,
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
});
if (nextScrollTop !== scroller.scrollTop) {
scroller.scrollTop = nextScrollTop;
}
}
function focusSearch(): boolean {
const input = searchInputRef.value;
if (!input) return false;
@ -225,6 +249,7 @@ defineExpose({ focusSearch });
</div>
<RecycleScroller
v-if="flatNodes.length > 0 && useVirtualTree"
ref="treeScrollerRef"
class="sidebar-tree connection-tree-scroller min-h-0 flex-1 overflow-y-auto overflow-x-auto"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
@ -241,6 +266,7 @@ defineExpose({ focusSearch });
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
@node-toggled="onNodeToggled"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
/>
@ -254,6 +280,7 @@ defineExpose({ focusSearch });
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
@node-toggled="onNodeToggled"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
/>

View File

@ -139,6 +139,7 @@ const props = defineProps<{
const emit = defineEmits<{
"rename-started": [];
"node-toggled": [node: TreeNode, wasExpanded: boolean];
"search-toggle": [node: TreeNode];
}>();
@ -260,11 +261,13 @@ async function toggle() {
if (node.type === "connection-group") {
node.isExpanded = !node.isExpanded;
connectionStore.toggleConnectionGroupCollapsed(node.id);
emit("node-toggled", node, wasExpanded);
return;
}
if (node.type === "saved-sql-root" || node.type === "saved-sql-folder") {
node.isExpanded = !node.isExpanded;
emit("node-toggled", node, wasExpanded);
return;
}
@ -275,11 +278,13 @@ async function toggle() {
node.type === "group-functions"
) {
node.isExpanded = !node.isExpanded;
emit("node-toggled", node, wasExpanded);
return;
}
if (node.isExpanded) {
node.isExpanded = false;
emit("node-toggled", node, wasExpanded);
return;
}
@ -324,6 +329,7 @@ async function toggle() {
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
}
emit("node-toggled", node, wasExpanded);
} catch (e: any) {
if (!wasExpanded) node.isExpanded = false;
const errMsg = e?.message || String(e);

View File

@ -29,3 +29,27 @@ export function flattenTree(nodes: TreeNode[]): FlatTreeNode[] {
export function shouldVirtualizeFlatTree(count: number): boolean {
return count > 0;
}
export function scrollTopForExpandedTreeNode(options: {
expandedIndex: number;
insertedRowCount: number;
currentScrollTop: number;
viewportHeight: number;
rowHeight?: number;
}): number {
const rowHeight = options.rowHeight ?? SIDEBAR_TREE_ROW_HEIGHT;
if (options.expandedIndex < 0 || options.insertedRowCount <= 0 || options.viewportHeight <= 0) {
return options.currentScrollTop;
}
const visibleRowCapacity = Math.max(1, Math.floor(options.viewportHeight / rowHeight) - 1);
const rowsToReveal = Math.min(options.insertedRowCount, visibleRowCapacity);
const expandedContentBottom = (options.expandedIndex + 1 + rowsToReveal) * rowHeight;
const viewportBottom = options.currentScrollTop + options.viewportHeight;
if (expandedContentBottom <= viewportBottom) {
return options.currentScrollTop;
}
return Math.max(0, expandedContentBottom - options.viewportHeight);
}

View File

@ -5,6 +5,7 @@ import {
SIDEBAR_TREE_PRERENDER_COUNT,
SIDEBAR_TREE_SCROLL_BUFFER,
flattenTree,
scrollTopForExpandedTreeNode,
shouldVirtualizeFlatTree,
} from "../../apps/desktop/src/composables/useFlatTree.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
@ -57,3 +58,27 @@ test("sidebar virtual tree keeps enough buffered rows for fast scrolling", () =>
test("sidebar virtual tree prerenders enough rows for the first frame", () => {
assert.ok(SIDEBAR_TREE_PRERENDER_COUNT >= 40);
});
test("expanded sidebar nodes scroll enough to reveal inserted rows", () => {
assert.equal(
scrollTopForExpandedTreeNode({
expandedIndex: 20,
insertedRowCount: 2,
currentScrollTop: 15 * SIDEBAR_TREE_ROW_HEIGHT,
viewportHeight: 6 * SIDEBAR_TREE_ROW_HEIGHT,
}),
17 * SIDEBAR_TREE_ROW_HEIGHT,
);
});
test("expanded sidebar nodes keep scroll position when children are already visible", () => {
assert.equal(
scrollTopForExpandedTreeNode({
expandedIndex: 4,
insertedRowCount: 2,
currentScrollTop: 0,
viewportHeight: 8 * SIDEBAR_TREE_ROW_HEIGHT,
}),
0,
);
});