feat(sidebar-files): restore the tree's scroll position across tab switches

This commit is contained in:
Yif committed 2026-09-12 00:07:50 +08:00
1 parent adbe7e6395
commit 004dceed5e
8 files changed
+58 -11

No files matched your search

@@ -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
+1 -1
View File
@@ -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.
<a id="model-experience"></a>
## Model Experience
@@ -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,其后才结算的列表什么也不写。
<a id="model-experience"></a>
## 模型体验
@@ -176,7 +176,16 @@ export function FilesBody({
const state = useStore(store => store.byTab[tab.id])
const pathRef = useRef<HTMLDivElement>(null)
const pathTextRef = useRef<HTMLSpanElement>(null)
const bodyRef = useRef<HTMLDivElement>(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({
</button>
</div>
{/* jscpd:ignore-end */}
<div className={css.body}>
<div
ref={bodyRef}
className={css.body}
data-files-body
onScroll={(event) => { actions.scrolled(tab.id, event.currentTarget.scrollTop) }}
>
<ul className={css.level}><Level path={state.root} tree={tree} /></ul>
</div>
</div>
@@ -1,6 +1,6 @@
/**
* The file tree's view state: which directories are expanded, and what each
* loaded level contains.
* The file tree's view state: which directories are expanded, what each
* loaded level contains, and where the body is scrolled to.
*
* The tree is not one resource. A directory listing per level, expanded lazily,
* is state the type owns — so it lives in a Slot-standard exclusive store
@@ -47,6 +47,8 @@ export interface FilesTabState {
levels: Record<string, LevelState>
/** Expanded absolute directory paths, root included. */
expanded: string[]
/** The body's scroll offset in px, so a remounted tree comes back where the reader was. */
scrollTop: number
}
/** Every tab's tree, keyed by tab id. */
@@ -74,6 +76,7 @@ type FilesActions = {
loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
toggled: (draft: FilesState, tabId: TabId, path: string) => void
scrolled: (draft: FilesState, tabId: TabId, scrollTop: number) => void
reset: (draft: FilesState, tabId: TabId) => void
forget: (draft: FilesState, tabId: TabId) => void
}
@@ -96,7 +99,7 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param root - absolute path of the workspace root.
*/
start: (d, tabId: TabId, root: string) => {
d.byTab[tabId] = { root, levels: {}, expanded: [root] }
d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0 }
},
/**
* Mark one directory as being listed.
@@ -141,6 +144,15 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
if (at >= 0) state.expanded.splice(at, 1)
else state.expanded.push(path)
},
/**
* Record where one tab's body is scrolled to.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param scrollTop - the body's scroll offset, in px.
*/
scrolled: (d, tabId: TabId, scrollTop: number) => {
bucket(d, tabId).scrollTop = scrollTop
},
/**
* Drop every loaded level, keeping what is expanded.
*
@@ -184,6 +184,17 @@ describe('FilesBody', () => {
expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload)
})
it('captures the body scroll offset and restores it when a tab switch remounts the tree', async () => {
const { view, script, instance, remount } = mountBody()
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const body = view.container.querySelector('[data-files-body]')!
fireEvent.scroll(body, { target: { scrollTop: 120 } })
expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(120)
view.unmount()
const back = remount()
expect(back.container.querySelector('[data-files-body]')!.scrollTop).toBe(120)
})
it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => {
const { view, script, controller, instance } = mountBody()
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
@@ -53,6 +53,8 @@ export interface Mounted {
readonly face: FilesInjected
readonly controller: AbortController
readonly tabActions: MockedTabActions
/** Render a fresh body over the same store and face, as a tab switch remounts it. */
readonly remount: () => RenderResult
}
/** One store instance, one face, one owner share. */
@@ -96,5 +98,5 @@ function harness(cwd: string | null) {
export function mountBody(cwd: string | null = ROOT): Mounted {
const { shared, ...hands } = harness(cwd)
const view = render(<FilesBody {...shared as unknown as FilesBodyProps} />)
return { ...hands, view }
return { ...hands, view, remount: () => render(<FilesBody {...shared as unknown as FilesBodyProps} />) }
}
@@ -33,7 +33,7 @@ describe('createFilesStore', () => {
const { actions } = store
const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
actions.start(TAB, ROOT)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT] })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0 })
})
it('walks one level through loading, ready, and failed', () => {
@@ -75,7 +75,15 @@ describe('createFilesStore', () => {
actions.toggled(TAB, child)
actions.loaded(TAB, child, LEVEL)
actions.reset(TAB)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child] })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 0 })
})
it('remembers where the body is scrolled to', () => {
const store = createFilesStore().create()
const { actions } = store
actions.start(TAB, ROOT)
actions.scrolled(TAB, 120)
expect(store.getSnapshot().byTab[TAB]!.scrollTop).toBe(120)
})
it('refuses to write a level for a tab that was never started', () => {