From 369c63979d4697f57b1d4000225aa48088116cbc Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sat, 31 Oct 2026 23:44:34 +0800 Subject: [PATCH] fix(project): keep the tree viewport when expanding shifts rows above (#579) The tree followed the lead row by index, so expanding or collapsing a folder above the selection shifted the index and scrolled back to the selected row. It now scrolls when the lead changes. A lead whose row has not loaded yet is kept pending and scrolled to once when the row appears, so preselected rows and async loads still come into view. Covers keyboard navigation, speed search, Select Opened File and autoscroll from source, which all still scroll to the new lead. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/ui/Tree.test.tsx | 104 ++++++++++++++++++++++++++++++++++++++++++- src/ui/Tree.tsx | 19 +++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index 0c238cf7b..b75891616 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; import { createRef } from "react"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { Tree, type TreeHandle } from "./Tree"; @@ -364,3 +364,105 @@ describe("Tree horizontal scrolling", () => { } }); }); + +describe("Tree scroll following", () => { + let scrollTo: ReturnType; + let original: PropertyDescriptor | undefined; + + beforeAll(() => { + // jsdom has no Element.scrollTo; virtual-core calls it to write scrollTop. + scrollTo = vi.fn(); + original = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTo"); + Object.defineProperty(Element.prototype, "scrollTo", { value: scrollTo, configurable: true, writable: true }); + }); + + afterAll(() => { + if (original) Object.defineProperty(Element.prototype, "scrollTo", original); + else delete (Element.prototype as { scrollTo?: unknown }).scrollTo; + }); + + it("brings the keyboard lead into view", () => { + const { key } = setup(); + scrollTo.mockClear(); + key("End"); + expect(scrollTo).toHaveBeenCalled(); + }); + + it("keeps the viewport when expanding a folder above the selection", () => { + // The selected file stays selected while rows inserted above it shift its + // index; scrolling then would jump to the selection from the opened folder. + const { tree } = setup({ defaultSelected: ["README.md"] }); + scrollTo.mockClear(); + const chevron = tree.querySelector("[data-tree-toggle]"); + expect(chevron).not.toBeNull(); + fireEvent.mouseDown(chevron!, { button: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + // The folder did open, inserting rows above the selection. + expect(within(tree).queryAllByRole("treeitem").length).toBeGreaterThan(2); + }); + + it.each(["defaultSelected", "selected"] as const)("scrolls a late %s lead once when its row loads", (selection) => { + const view = (roots: N[]) => ( + + roots={roots} + getKey={(n) => n.id} + getLabel={(n) => n.id} + getChildren={(n) => n.children} + isExpandable={(n) => n.id === "src" || !!n.children} + defaultExpanded={["src"]} + {...{ [selection]: new Set(["late.ts"]) }} + /> + ); + const { rerender } = render(view([{ id: "src" }])); + scrollTo.mockClear(); + // An intermediate load still does not contain the selected row. + rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }] }])); + expect(scrollTo).not.toHaveBeenCalled(); + rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }, { id: "late.ts" }] }])); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(screen.getByRole("treeitem", { name: "late.ts" })).toHaveAttribute("aria-selected", "true"); + scrollTo.mockClear(); + const folder = screen.getByRole("treeitem", { name: "main" }); + fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 }); + expect(screen.getByRole("treeitem", { name: "App.tsx" })).toBeInTheDocument(); + expect(scrollTo).not.toHaveBeenCalled(); + fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("replaces a pending lead when the user selects another row", () => { + const view = (roots: N[]) => ( + + roots={roots} + getKey={(n) => n.id} + getLabel={(n) => n.id} + getChildren={(n) => n.children} + isExpandable={(n) => !!n.children} + defaultSelected={["late.ts"]} + /> + ); + const { rerender } = render(view([{ id: "README.md" }])); + fireEvent.keyDown(screen.getByRole("tree"), { key: "End" }); + scrollTo.mockClear(); + rerender(view([{ id: "late.ts" }, { id: "README.md" }])); + expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true"); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("explicitly reveals an unchanged lead", () => { + const handle = createRef(); + setup({ handleRef: handle, defaultSelected: ["README.md"] }); + scrollTo.mockClear(); + act(() => handle.current!.reveal("README.md")); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + + it("brings speed search matches into view", () => { + const { key, lead } = setup({ defaultExpanded: ["src", "main"] }); + key("Home"); + scrollTo.mockClear(); + key("u"); + expect(lead()).toBe("util.ts"); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index cd9596f10..eedf4181c 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -307,9 +307,24 @@ export function Tree(props: TreeProps) { getItemKey: (i) => rows[i]!.key, }); + // Keep the keyboard lead in view when the lead changes — not when its index + // shifts because expanding or collapsing elsewhere inserted rows above it: + // following the index would yank the viewport from the folder the user just + // opened back to the selection. + const pendingScrollToLead = useRef(null); useEffect(() => { - if (leadIndex >= 0) virtualizer.scrollToIndex(leadIndex, { align: "auto" }); - }, [leadIndex, virtualizer]); + pendingScrollToLead.current = lead; + }, [lead]); + // A preselected lead may arrive before its row is loaded. Retry until it + // appears, then stop following index changes for that selection. + useEffect(() => { + const key = pendingScrollToLead.current; + if (key == null) return; + const i = indexOfKey.get(key); + if (i === undefined) return; + virtualizer.scrollToIndex(i, { align: "auto" }); + pendingScrollToLead.current = null; + }, [lead, indexOfKey, virtualizer]); // Speed search owns Escape while active. useEffect(() => {