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 */} -
+
{ actions.scrolled(tab.id, event.currentTarget.scrollTop) }} + >
diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts index 619ed0ba30..468b8b17de 100644 --- a/packages/client/ui-sidebar-files/src/client/store.ts +++ b/packages/client/ui-sidebar-files/src/client/store.ts @@ -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 /** 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 * @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 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. * diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index 51bf217312..e79e60b6e1 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -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 })) diff --git a/packages/client/ui-sidebar-files/tests/mount.client.tsx b/packages/client/ui-sidebar-files/tests/mount.client.tsx index 389c383a9e..54131b5e31 100644 --- a/packages/client/ui-sidebar-files/tests/mount.client.tsx +++ b/packages/client/ui-sidebar-files/tests/mount.client.tsx @@ -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() - return { ...hands, view } + return { ...hands, view, remount: () => render() } } diff --git a/packages/client/ui-sidebar-files/tests/store.client.spec.ts b/packages/client/ui-sidebar-files/tests/store.client.spec.ts index ea364c564e..c85ec67a9a 100644 --- a/packages/client/ui-sidebar-files/tests/store.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/store.client.spec.ts @@ -33,7 +33,7 @@ describe('createFilesStore', () => { const { actions } = store const getSnapshot = (): ReturnType => 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', () => {