feat(sidebar-files): restore the tree's scroll position across tab switches
This commit is contained in:
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
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user