Browse Source

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

Yif 1 month ago
parent
commit
004dceed5e

+ 2 - 2
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;
 # 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:
 # 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
 #   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
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.
 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>
 <a id="model-experience"></a>
 ## Model Experience
 ## Model Experience

+ 1 - 1
packages/client/ui-sidebar-files/README.zh.md

@@ -43,7 +43,7 @@ kind: "package-reference"
 
 
 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`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>
 <a id="model-experience"></a>
 ## 模型体验
 ## 模型体验

+ 15 - 1
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 state = useStore(store => store.byTab[tab.id])
   const pathRef = useRef<HTMLDivElement>(null)
   const pathRef = useRef<HTMLDivElement>(null)
   const pathTextRef = useRef<HTMLSpanElement>(null)
   const pathTextRef = useRef<HTMLSpanElement>(null)
+  const bodyRef = useRef<HTMLDivElement>(null)
   usePathClipped(pathRef, pathTextRef, state?.root)
   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(() => {
   useEffect(() => {
     // A bucket gone because the record aborted must not be re-seeded by a
     // A bucket gone because the record aborted must not be re-seeded by a
     // component that has not unmounted yet.
     // component that has not unmounted yet.
@@ -228,7 +237,12 @@ export function FilesBody({
         </button>
         </button>
       </div>
       </div>
       {/* jscpd:ignore-end */}
       {/* 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>
         <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
       </div>
       </div>
     </div>
     </div>

+ 15 - 3
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,
  * 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
  * 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>
   levels: Record<string, LevelState>
   /** Expanded absolute directory paths, root included. */
   /** Expanded absolute directory paths, root included. */
   expanded: string[]
   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. */
 /** Every tab's tree, keyed by tab id. */
@@ -74,6 +76,7 @@ type FilesActions = {
   loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
   loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
   failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
   failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
   toggled: (draft: FilesState, tabId: TabId, path: string) => void
   toggled: (draft: FilesState, tabId: TabId, path: string) => void
+  scrolled: (draft: FilesState, tabId: TabId, scrollTop: number) => void
   reset: (draft: FilesState, tabId: TabId) => void
   reset: (draft: FilesState, tabId: TabId) => void
   forget: (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.
        * @param root - absolute path of the workspace root.
        */
        */
       start: (d, tabId: TabId, root: string) => {
       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.
        * Mark one directory as being listed.
@@ -141,6 +144,15 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
         if (at >= 0) state.expanded.splice(at, 1)
         if (at >= 0) state.expanded.splice(at, 1)
         else state.expanded.push(path)
         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.
        * Drop every loaded level, keeping what is expanded.
        *
        *

+ 11 - 0
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)
     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 () => {
   it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => {
     const { view, script, controller, instance } = mountBody()
     const { view, script, controller, instance } = mountBody()
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))

+ 3 - 1
packages/client/ui-sidebar-files/tests/mount.client.tsx

@@ -53,6 +53,8 @@ export interface Mounted {
   readonly face: FilesInjected
   readonly face: FilesInjected
   readonly controller: AbortController
   readonly controller: AbortController
   readonly tabActions: MockedTabActions
   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. */
 /** 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 {
 export function mountBody(cwd: string | null = ROOT): Mounted {
   const { shared, ...hands } = harness(cwd)
   const { shared, ...hands } = harness(cwd)
   const view = render(<FilesBody {...shared as unknown as FilesBodyProps} />)
   const view = render(<FilesBody {...shared as unknown as FilesBodyProps} />)
-  return { ...hands, view }
+  return { ...hands, view, remount: () => render(<FilesBody {...shared as unknown as FilesBodyProps} />) }
 }
 }

+ 10 - 2
packages/client/ui-sidebar-files/tests/store.client.spec.ts

@@ -33,7 +33,7 @@ describe('createFilesStore', () => {
     const { actions } = store
     const { actions } = store
     const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
     const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
     actions.start(TAB, ROOT)
     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', () => {
   it('walks one level through loading, ready, and failed', () => {
@@ -75,7 +75,15 @@ describe('createFilesStore', () => {
     actions.toggled(TAB, child)
     actions.toggled(TAB, child)
     actions.loaded(TAB, child, LEVEL)
     actions.loaded(TAB, child, LEVEL)
     actions.reset(TAB)
     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', () => {
   it('refuses to write a level for a tab that was never started', () => {