diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts
index 68dd75f3d8..6616ced720 100644
--- a/apps/web/tests/details-session-lifecycle.e2e.ts
+++ b/apps/web/tests/details-session-lifecycle.e2e.ts
@@ -397,9 +397,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
expect(await paneSnapshot(page)).toEqual(retainedB)
await open()
- await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('false')
+ await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
expect(await paneSnapshot(page)).toEqual(retainedB)
- await checkpoint('B restored: normal mode and collapsed Files directory')
+ await checkpoint('B restored: normal mode and expanded Files directory')
await close()
await select(original, 'LIGHTHOUSE')
await expect.poll(() => columns(page)).toEqual(normalColumns)
diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml
index c9475ae68a..61b61fec3b 100644
--- a/packages/client/ui-sidebar-files/README.i18n.yaml
+++ b/packages/client/ui-sidebar-files/README.i18n.yaml
@@ -18,8 +18,8 @@
en: ed52405be7c3fa24
zh: d06dbd49c786c5f0
/deepseek-ai-dsh-client-ui-sidebar-files/the-tree:
- en: a0ae0b1f223b3df1
- zh: 8ddc325519bd75b2
+ en: 5c814db14f518cbf
+ zh: 52e54e3c901f8759
/deepseek-ai-dsh-client-ui-sidebar-files/model-experience:
en: a18a43f6842f1371
zh: 69f2a2b35f06dfc7
@@ -27,8 +27,8 @@
en: 5a6febb45089991a
zh: b17ed755fd098b83
/deepseek-ai-dsh-client-ui-sidebar-files/known-limitations-and-deferred-work:
- en: c0e1831d8975186c
- zh: 332aac8738458bb0
+ en: 858459fe95f1f1d1
+ zh: 2a51243a80ae3187
/deepseek-ai-dsh-client-ui-sidebar-files/known-limitations-and-deferred-work/dev-note:
en: d8b51fb935f38650
zh: fee9d330ecf8ec0e
diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md
index 5c04f195d1..2189b17b73 100644
--- a/packages/client/ui-sidebar-files/README.md
+++ b/packages/client/ui-sidebar-files/README.md
@@ -45,7 +45,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 refreshes the root and expanded levels in place, retaining displayed entries during reads instead of resetting the whole tree; 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), `expanded`, `autoRefresh`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. 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 neither a listing that settles afterwards nor the unmount's offset commit writes anything.
+State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, `autoRefresh`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. 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 store instance ends with its Session binding when the reader leaves the Session, so the body also hands the tree it last drew to the face when it unmounts, which retains it per Session and tab until the tab closes; returning to the Session seeds the new store with that tree when the workspace root is unchanged and replaces the earlier binding's directory watches. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything.
Each open `DirectoryNode` owns its target watch for the Tab lifetime; collapse closes that node and its hidden descendants. Expansion changes during ancestor restoration update the store and pending nodes, so restored descendants follow the latest expansion preferences. Automatic refresh defaults to enabled; its separate toggle is hidden while state, labels, styles, and toggle logic remain. Changes received during a directory read or its completion remain pending for another refresh.
@@ -63,6 +63,7 @@ None; directory listings travel over the Remote and assemble no model request.
- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, or current-file highlight.
- **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway.
+- **Watches outlive a released Session.** The face cannot observe a Session binding ending, so a tab's directory watches keep running after the reader leaves the Session until the reader returns or the tab closes; the retained tree, with its loaded listings, is held for the same period.
### Dev Note
diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md
index ec092a362d..2a716264c4 100644
--- a/packages/client/ui-sidebar-files/README.zh.md
+++ b/packages/client/ui-sidebar-files/README.zh.md
@@ -45,7 +45,7 @@ kind: "package-reference"
被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取就地刷新根与展开中的层,读取期间保留显示条目,不重置整棵树;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
-状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
+状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。读者离开 Session 时,存储实例随其 Session 绑定一同结束,因此 body 卸载时还会把它最后绘制的树交给 face,face 按 Session 与 tab 保留它直到 tab 关闭;回到该 Session 时,若工作区根目录未变,新存储以这棵树为初值,并替换先前绑定的目录监听。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。
@@ -63,6 +63,7 @@ kind: "package-reference"
- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。
- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。
+- **监听比已释放的 Session 存活更久。**face 无法观察到 Session 绑定的结束,因此读者离开 Session 后,tab 的目录监听会持续运行,直到读者返回或 tab 关闭;保留的树及其已加载的列表也在同一期间保留。
### 开发备注
diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
index 2aa024b649..c2ae6e7f99 100644
--- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
+++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
@@ -139,7 +139,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode {
/** The file tree's body: the workspace root and whatever the reader has opened under it. */
export function FilesBody({
useTabInfo, sessionId, useSessions, useStore, actions,
- start, refresh, setAutoRefresh, toggle, t, renderSlot,
+ start, refresh, setAutoRefresh, toggle, retain, t, renderSlot,
}: FilesBodyProps): ReactNode {
const { tab } = useTabInfo()
useEffect(() => tab.actions.bindCommands({ refresh: () => { refresh(tab.id) } }), [tab.actions, tab.id, refresh])
@@ -149,7 +149,8 @@ export function FilesBody({
const bodyRef = useRef(null)
const scrollTopRef = useRef(0)
// 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
+ // store, or in the face's retained tree once the Session's store was
+ // released, 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(() => {
@@ -159,12 +160,17 @@ export function FilesBody({
scrollTopRef.current = body.scrollTop
}
}, [seeded])
- // Scrolling only moves the ref; the store hears about it once, on unmount,
- // so a scroll neither re-renders the tree nor writes after the owner's
- // abort has forgotten the bucket.
+ const treeRef = useRef(state)
+ useEffect(() => { treeRef.current = state }, [state])
+ // Scrolling only moves the ref; the store and the face hear about it once,
+ // on unmount, so a scroll neither re-renders the tree nor writes after the
+ // owner's abort has forgotten the bucket.
useEffect(() => () => {
- if (seeded && !signal.aborted) actions.scrolled(tab.id, scrollTopRef.current)
- }, [seeded, signal, tab.id, actions])
+ const tree = treeRef.current
+ if (tree === undefined || signal.aborted) return
+ actions.scrolled(tab.id, scrollTopRef.current)
+ retain(tab.id, { ...tree, scrollTop: scrollTopRef.current })
+ }, [signal, tab.id, actions, retain])
useEffect(() => {
// A bucket gone because the record aborted must not be re-seeded by a
// component that has not unmounted yet.
diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts
index f1a5c13d96..a8cf91bf55 100644
--- a/packages/client/ui-sidebar-files/src/client/face.ts
+++ b/packages/client/ui-sidebar-files/src/client/face.ts
@@ -17,12 +17,19 @@
* `signal`: a request is not made for a record that already ended, and when the
* record goes away the bucket and the tab's listing bookkeeping are forgotten,
* so no later settlement writes to it.
+ *
+ * The store instance lives only as long as its Session binding, which ends when
+ * the reader leaves the Session while its tab records stay open. Each tab's last
+ * drawn tree is therefore retained beside the per-binding faces, keyed by Session
+ * and tab, and seeds the tab again when the reader returns. The latest binding
+ * to start a tab owns it: its start closes the earlier binding's watches, and
+ * only its body's tree is retained.
*/
import type { ClientRemote, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { DirLevel, createFilesStore } from './store.ts'
+import type { DirLevel, FilesTabState, createFilesStore } from './store.ts'
import type { WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { DirectoryNode } from './directory-node.ts'
@@ -141,6 +148,21 @@ export interface FilesInjected {
* @param signal - the tab record's lifetime.
*/
readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void
+ /**
+ * Keep the tree a body last drew, for a later binding of the same Session.
+ * Does nothing once the tab record ended or a later binding started the tab.
+ * @param tabId - the tab being drawn.
+ * @param tree - the tab's tree, with the body's final scroll offset.
+ */
+ readonly retain: (tabId: TabId, tree: FilesTabState) => void
+}
+
+/** One open tab's directory watches and, once its body unmounted, its last drawn tree. */
+interface RetainedTab {
+ readonly node: DirectoryNode
+ /** Detaches the owning binding's tab-close cleanup when a later binding takes the tab over. */
+ readonly detach: AbortController
+ tree?: FilesTabState
}
/**
@@ -153,6 +175,7 @@ export function filesFace(
list: ListWorkspaceDirectory,
watch: WatchWorkspaceDirectory,
): (sessionId: SessionId, actions: BoundActions>) => FilesInjected {
+ const retained = new Map>()
return (
sessionId: SessionId,
actions: BoundActions>,
@@ -187,20 +210,38 @@ export function filesFace(
roots.get(tabId)?.setAutomatic(enabled)
},
start(tabId, root, signal) {
- actions.start(tabId, root)
+ let tabs = retained.get(sessionId)
+ if (tabs === undefined) retained.set(sessionId, tabs = new Map())
+ const previous = tabs.get(tabId)
+ // The previous binding's watches and cleanup belong to a released store.
+ previous?.detach.abort()
+ void previous?.node.close()
+ const detach = new AbortController()
+ const tree = previous?.tree?.root === root ? previous.tree : undefined
+ if (tree === undefined) actions.start(tabId, root)
+ else actions.restore(tabId, tree)
signal.addEventListener('abort', () => {
void roots.get(tabId)?.close()
roots.delete(tabId)
generations.delete(tabId)
+ tabs.delete(tabId)
+ if (tabs.size === 0) retained.delete(sessionId)
actions.forget(tabId)
- }, { once: true })
- roots.set(tabId, new DirectoryNode(root,
+ }, { once: true, signal: detach.signal })
+ const node = new DirectoryNode(root,
(path, lifetime) => load(tabId, path, lifetime),
(path, lifetime) => watch(sessionId, path, lifetime),
(path, error) => {
if (!signal.aborted) actions.failed(tabId, path, new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}))
- }, signal,
- ).open())
+ }, signal, tree?.expanded,
+ )
+ if (tree?.autoRefresh === false) node.setAutomatic(false)
+ roots.set(tabId, node.open())
+ tabs.set(tabId, { node, detach })
+ },
+ retain: (tabId, tree) => {
+ const tab = retained.get(sessionId)?.get(tabId)
+ if (tab !== undefined && roots.get(tabId) === tab.node) tab.tree = tree
},
load: (tabId, path, signal) => { void load(tabId, path, signal) },
toggle(tabId, parentPath, path, expanded, signal) {
diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts
index 897a04a46f..e245864434 100644
--- a/packages/client/ui-sidebar-files/src/client/store.ts
+++ b/packages/client/ui-sidebar-files/src/client/store.ts
@@ -74,6 +74,7 @@ function bucket(state: FilesState, tabId: TabId): FilesTabState {
type FilesActions = {
autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void
start: (draft: FilesState, tabId: TabId, root: string) => void
+ restore: (draft: FilesState, tabId: TabId, tree: FilesTabState) => void
loading: (draft: FilesState, tabId: TabId, path: string) => void
loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
@@ -104,6 +105,15 @@ export function createFilesStore(): EngineStoreHandle
start: (d, tabId: TabId, root: string) => {
d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0, autoRefresh: true }
},
+ /**
+ * Seed one tab with the tree it showed before its Session's store was released.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param tree - the tab's retained tree.
+ */
+ restore: (d, tabId: TabId, tree: FilesTabState) => {
+ d.byTab[tabId] = tree
+ },
/**
* Mark one directory as being listed.
* @param d - draft state.
diff --git a/packages/client/ui-sidebar-files/tests/face.client.spec.ts b/packages/client/ui-sidebar-files/tests/face.client.spec.ts
index 29de3825cc..e83a98461d 100644
--- a/packages/client/ui-sidebar-files/tests/face.client.spec.ts
+++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts
@@ -281,6 +281,97 @@ describe('filesFace', () => {
})
})
+describe('filesFace retention across Session store instances', () => {
+ it('seeds a returning tab with its retained tree and automatic setting, and starts fresh at another root', async () => {
+ const script = scriptedList()
+ const bind = filesFace(script.list, script.watch)
+ const controller = new AbortController()
+ onTestFinished(async () => {
+ controller.abort()
+ await script.dispose()
+ })
+ const first = createFilesStore().create()
+ const firstFace = bind(SESSION, first.actions)
+ firstFace.start(TAB, ROOT, controller.signal)
+ const firstStream = await script.watches.ready(ROOT)
+ await script.settle({ ok: true, value: LEVEL })
+ firstFace.setAutoRefresh(TAB, false)
+ firstFace.retain(TAB, { ...first.getSnapshot().byTab[TAB]!, scrollTop: 40 })
+
+ const second = createFilesStore().create()
+ bind(SESSION, second.actions).start(TAB, ROOT, controller.signal)
+ expect(firstStream.signal.aborted).toBe(true)
+ // Only the current binding's cleanup forgets the tab when it closes.
+ const forgetFirst = vi.spyOn(first.actions, 'forget')
+ expect(second.getSnapshot().byTab[TAB]).toEqual({
+ root: ROOT, expanded: [ROOT], levels: { [ROOT]: { kind: 'ready', level: LEVEL } }, scrollTop: 40, autoRefresh: false,
+ })
+ const secondStream = await script.watches.ready(ROOT, 1)
+ await script.settle({ ok: true, value: LEVEL })
+ await secondStream.deliver('change')
+ expect(script.list).toHaveBeenCalledTimes(2)
+
+ const third = createFilesStore().create()
+ const thirdFace = bind(SESSION, third.actions)
+ thirdFace.retain(TAB, { ...second.getSnapshot().byTab[TAB]!, scrollTop: 40 })
+ thirdFace.start(TAB, '/work/other', controller.signal)
+ expect(third.getSnapshot().byTab[TAB]).toEqual({ root: '/work/other', expanded: ['/work/other'], levels: {}, scrollTop: 0, autoRefresh: true })
+ controller.abort()
+ expect(third.getSnapshot().byTab[TAB]).toBeUndefined()
+ expect(second.getSnapshot().byTab[TAB]).toBeDefined()
+ expect(forgetFirst).not.toHaveBeenCalled()
+ })
+
+ it('ignores a tree retained by a binding that a later binding replaced', () => {
+ const script = scriptedList()
+ const bind = filesFace(script.list, script.watch)
+ const controller = new AbortController()
+ onTestFinished(async () => {
+ controller.abort()
+ await script.dispose()
+ })
+ const first = createFilesStore().create()
+ const firstFace = bind(SESSION, first.actions)
+ firstFace.start(TAB, ROOT, controller.signal)
+ const second = createFilesStore().create()
+ const secondFace = bind(SESSION, second.actions)
+ secondFace.start(TAB, ROOT, controller.signal)
+ secondFace.retain(TAB, { ...second.getSnapshot().byTab[TAB]!, scrollTop: 30 })
+ firstFace.retain(TAB, { ...first.getSnapshot().byTab[TAB]!, scrollTop: 90 })
+ const third = createFilesStore().create()
+ bind(SESSION, third.actions).start(TAB, ROOT, controller.signal)
+ expect(third.getSnapshot().byTab[TAB]?.scrollTop).toBe(30)
+ })
+
+ it('forgets a closed tab, so a later record with its id starts fresh, and keeps the other tabs', async () => {
+ const script = scriptedList()
+ const bind = filesFace(script.list, script.watch)
+ onTestFinished(() => script.dispose())
+ const closed = new AbortController()
+ const kept = new AbortController()
+ onTestFinished(() => { kept.abort() })
+ const other = 'tab-2' as TabId
+ const first = createFilesStore().create()
+ const firstFace = bind(SESSION, first.actions)
+ firstFace.start(TAB, ROOT, closed.signal)
+ firstFace.start(other, ROOT, kept.signal)
+ firstFace.retain(other, { ...first.getSnapshot().byTab[other]!, scrollTop: 25 })
+ const tree = { ...first.getSnapshot().byTab[TAB]!, scrollTop: 40 }
+ firstFace.retain(TAB, tree)
+ closed.abort()
+ // A retain after the record ended has no tab to attach to.
+ firstFace.retain(TAB, tree)
+ const reopened = new AbortController()
+ onTestFinished(() => { reopened.abort() })
+ const second = createFilesStore().create()
+ const secondFace = bind(SESSION, second.actions)
+ secondFace.start(TAB, ROOT, reopened.signal)
+ secondFace.start(other, ROOT, kept.signal)
+ expect(second.getSnapshot().byTab[TAB]?.scrollTop).toBe(0)
+ expect(second.getSnapshot().byTab[other]?.scrollTop).toBe(25)
+ })
+})
+
describe('createList', () => {
it('passes the session, the absolute path, and the signal through, and keeps entries and truncation', async () => {
const listing: WorkspaceDirectoryListing = {
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 cee2a11f44..6c2a0fa037 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
@@ -270,6 +270,31 @@ describe('FilesBody', () => {
expect(back.container.querySelector('[data-files-body]')!.scrollTop).toBe(120)
})
+ it('restores the tree and scroll offset under a new Session store and replaces the earlier watches', async () => {
+ const { view, script, rebind } = mountBody()
+ const child = `${ROOT}/src`
+ const rootStream = await act(() => script.watches.ready(ROOT))
+ await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+ act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) })
+ const childStream = await act(() => script.watches.ready(child))
+ await act(() => script.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } }))
+ fireEvent.scroll(view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 120 } })
+ view.unmount()
+
+ const back = await act(() => rebind())
+ expect(rootStream.signal.aborted).toBe(true)
+ expect(childStream.signal.aborted).toBe(true)
+ expect(names(back.view.container)).toContain(`${child}/a.ts`)
+ expect(back.view.container.querySelector('[data-files-body]')!.scrollTop).toBe(120)
+ expect(back.instance.getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child])
+ await act(() => script.watches.ready(ROOT, 1))
+ await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+ await act(() => script.watches.ready(child, 1))
+ await act(() => script.settle({ ok: true, value: { entries: [{ name: 'b.ts', type: 'file' }], truncated: false } }))
+ expect(names(back.view.container)).toContain(`${child}/b.ts`)
+ expect(names(back.view.container)).not.toContain(`${child}/a.ts`)
+ })
+
it('a scroll before the owner aborts is not written to a forgotten bucket', async () => {
const { view, script, controller, instance } = mountBody()
await act(() => script.watches.ready(ROOT))
diff --git a/packages/client/ui-sidebar-files/tests/mount.client.tsx b/packages/client/ui-sidebar-files/tests/mount.client.tsx
index 5646e773fb..7662b4284c 100644
--- a/packages/client/ui-sidebar-files/tests/mount.client.tsx
+++ b/packages/client/ui-sidebar-files/tests/mount.client.tsx
@@ -56,13 +56,16 @@ export interface Mounted {
readonly tabActions: MockedTabActions
/** Render a fresh body over the same store and face, as a tab switch remounts it. */
readonly remount: () => RenderResult
+ /** Render a fresh body over a new store instance and face, as returning to a released Session binding does. */
+ readonly rebind: () => { readonly view: RenderResult; readonly instance: FilesStoreInstance }
}
/** One store instance, one face, one owner share. */
function harness(cwd: string | null, refreshShortcut?: ReturnType['tab']['refreshShortcut']) {
- const instance = createFilesStore().create()
const script = scriptedList()
- const face = filesFace(script.list, script.watch)(SESSION, instance.actions)
+ const bindFace = filesFace(script.list, script.watch)
+ const instance = createFilesStore().create()
+ const face = bindFace(SESSION, instance.actions)
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
@@ -75,6 +78,11 @@ function harness(cwd: string | null, refreshShortcut?: ReturnType(),
}
const sessions = { byId: cwd === null ? {} : { [SESSION]: { cwd } } } as unknown as SessionListState
+ const bind = (instance: FilesStoreInstance) => ({
+ useStore: hookOf(instance),
+ actions: instance.actions,
+ ...bindFace(SESSION, instance.actions),
+ })
const shared = {
// A page tab's address is the shell's to mint; the body never reads it.
useTabInfo: () => ({
@@ -95,7 +103,7 @@ function harness(cwd: string | null, refreshShortcut?: ReturnType['tab']['refreshShortcut']): Mounted {
- const { shared, ...hands } = harness(cwd, refreshShortcut)
- const view = render()
- return { ...hands, view, remount: () => render() }
+ const { shared: base, bind, ...hands } = harness(cwd, refreshShortcut)
+ const draw = (store?: ReturnType) => {
+ const shared = { ...base, ...store }
+ return render()
+ }
+ return {
+ ...hands,
+ view: draw(),
+ remount: () => draw(),
+ rebind: () => {
+ const instance = createFilesStore().create()
+ return { instance, view: draw(bind(instance)) }
+ },
+ }
}
diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json
index bef0f27ca0..4ac9fa8f61 100644
--- a/scripts/no-unknown-casts.baseline.json
+++ b/scripts/no-unknown-casts.baseline.json
@@ -645,7 +645,7 @@
"1bbd18e22567ce0529d02b770533f9bda84ff379b161dda8b9548053a18a0e97": 1
},
"packages/client/ui-sidebar-files/tests/mount.client.tsx": {
- "2caa9ae382deea09b4ac64586c1ab49bd935b1e689218e3d485bd7b918e22066": 2,
+ "2caa9ae382deea09b4ac64586c1ab49bd935b1e689218e3d485bd7b918e22066": 1,
"684eae6dc3d4dbe526c5a5b11ebd84e35e335bebd519cf09c0f3a24ba9280fc1": 1
},
"packages/client/ui-sidebar-files/tests/watch.client.spec.ts": {
diff --git a/snapshots/web/details-session-lifecycle/sidebar.expected.md b/snapshots/web/details-session-lifecycle/sidebar.expected.md
index 9ae98ff006..98a8e112ad 100644
--- a/snapshots/web/details-session-lifecycle/sidebar.expected.md
+++ b/snapshots/web/details-session-lifecycle/sidebar.expected.md
@@ -229,7 +229,7 @@
}
```
-## B restored: normal mode and collapsed Files directory
+## B restored: normal mode and expanded Files directory
```json
{
@@ -249,7 +249,9 @@
"panelOuterWidth": 756,
"coversViewport": false,
"resizeHandleWidth": 8,
- "expandedDirectories": [],
+ "expandedDirectories": [
+ "workspace"
+ ],
"panes": [
{
"active": true,