From 004dceed5e12ae511643920da3d3dc6f7949ef67 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Sat, 12 Sep 2026 00:07:50 +0800
Subject: [PATCH] feat(sidebar-files): restore the tree's scroll position
across tab switches
---
.../client/ui-sidebar-files/README.i18n.yaml | 4 ++--
packages/client/ui-sidebar-files/README.md | 2 +-
packages/client/ui-sidebar-files/README.zh.md | 2 +-
.../ui-sidebar-files/src/client/FilesBody.tsx | 16 +++++++++++++++-
.../ui-sidebar-files/src/client/store.ts | 18 +++++++++++++++---
.../tests/files-body.client.spec.tsx | 11 +++++++++++
.../ui-sidebar-files/tests/mount.client.tsx | 4 +++-
.../tests/store.client.spec.ts | 12 ++++++++++--
8 files changed, 58 insertions(+), 11 deletions(-)
diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml
index 855bf2710c..ee14cf7c76 100644
--- a/packages/client/ui-sidebar-files/README.i18n.yaml
+++ b/packages/client/ui-sidebar-files/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md
-README.md: 28e052b5a6739118c691431aad88964a8f6d19cd
-README.zh.md: fe5a10d19b3957f555c08eeaf6f48ceb44daecaf
+README.md: 24364178c6679d5450bdcb450176431595e44e53
+README.zh.md: a631e5a2df124004ad40c6bf3bf19ed35cc234f4
diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md
index 28e052b5a6..24364178c6 100644
--- a/packages/client/ui-sidebar-files/README.md
+++ b/packages/client/ui-sidebar-files/README.md
@@ -43,7 +43,7 @@ The root is the session's working directory, read from `useSessions().byId[sessi
A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree.
-State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), and `expanded`. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing.
+State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, and `scrollTop`. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing.
## Model Experience
diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md
index fe5a10d19b..a631e5a2df 100644
--- a/packages/client/ui-sidebar-files/README.zh.md
+++ b/packages/client/ui-sidebar-files/README.zh.md
@@ -43,7 +43,7 @@ kind: "package-reference"
被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
-状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)与 `expanded`。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
+状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
## 模型体验
diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
index 07c4b8be65..682091e9ad 100644
--- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
+++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
@@ -176,7 +176,16 @@ export function FilesBody({
const state = useStore(store => store.byTab[tab.id])
const pathRef = useRef(null)
const pathTextRef = useRef(null)
+ const bodyRef = useRef(null)
usePathClipped(pathRef, pathTextRef, state?.root)
+ // Come back where the reader was: loaded levels outlive the body in the
+ // store, so a remounted tree lays out at its full height before this runs
+ // and the stored offset re-lands exactly. A fresh tree stores 0.
+ const seeded = state !== undefined
+ useLayoutEffect(() => {
+ const body = bodyRef.current
+ if (seeded && body !== null) body.scrollTop = state.scrollTop
+ }, [seeded])
useEffect(() => {
// A bucket gone because the record aborted must not be re-seeded by a
// component that has not unmounted yet.
@@ -228,7 +237,12 @@ export function FilesBody({
{/* jscpd:ignore-end */}
-