fix(sidebar): 点击展开时文件树整体重建(两处根因)+ v0.24.1
用户报告:「点击展开的时候,文件树会频繁整体刷新,看起来非常难受」。
根因 1(主):src/client/native/index.ts 的 sync() 把 files 接管项当成多余注册。
它按 KIND 记账、永远不在 wanted 里,于是每次 sync 都被 dispose + 重建;而
disposeSubscriptions 同时订阅了 service 与 store,**任何会话状态写入**(展开/收起、
切 tab、拖底部工作台、选中…)都会跑一遍 sync。替换 slot 注册 = 宿主换 slot entry →
插件 tab body 被 unmount/remount → 文件树丢失层缓存、滚动位置与目录 watcher,并重新
fs.trees([root, ...expanded]) 重列整棵可见树。机械证明(临时插桩,已回退):
[probe-sync] enter live=[editor,git,subagent,sidechat,diff,files]
[probe-registerSlots] slot inject ran for dsh-better-sidebar:files
[probe-body] unmount tab3 kind=files
[probe-body] mount tab3 kind=files
修法:wantsFiles 提前,删除循环里对 files 接管项 skip(禁用 editor 时仍照旧释放)。
根因 2(次):FileTree.tsx 的 retryDir 先把该层缓存删掉再请求,行被「Loading…」
占位替换后重建——目录有外部写入(构建 / 格式化 / 模型跑 bash)时整层闪空。
修法:复用既有 force 路径 loadLevels([dir], { force: true }),保留旧行直到新答案到达
(与 refreshTick 的强制刷新路径行为一致)。
判别性证据(回退任一修复即红)
- tests/native-surface.spec.ts:三次 store 写入不得产生任何注册/注销 → 回退后
registered 6 → 12、disposed 全是 dsh-better-sidebar:files
- tests/file-tree-batch-load.spec.tsx:重列期间旧行仍在屏 → 回退后层被删空
- 新增真实宿主 lane tests/e2e/zz-expand-refresh.e2e.ts(quiescence 判据,无固定 sleep;
zz- 前缀保证它最后跑,因为它会自建 workspace + session):A1 展开只请求被展开那一层、
A2 tab body 存活(不重开 watch socket)、A3 屏上行数不归零;回退修复 1 后退出码 1
(Error: expanding a folder must ask for that folder, not the whole visible set)
修复后实测(40 子项目录,同一探针/机器/工作区)
| 阶段 | fs.trees payload | 新开 ws | DOM +/- | 被删行 | body 替换 | 层内最小行数 |
| 展开 big | [big](原 [root, big]) | 0(原 1) | +41/-1 | 0(原 45) | 0(原是) | 40(原 40) |
| 外部写入 big | [big] | 0 | +1/-0(原 +41/-40) | 0 | 0 | 40(原 0) |
| 展开 bravo | [bravo] | 0 | +2/-1 | 0(原 45) | 0(原是) | 41 |
证伪记录:新建 fs.watch(dir,{persistent:false}) 在 macOS 上无改动时 0 事件(Node 微
探针两遍 + 8 轮真实时序 + 浏览器侧从未收到 {dir} 陈旧帧)→ src/fs-watch.ts 与
use-dir-watch.ts 一行未改。
验证:pnpm typecheck / lint / test(136 文件 1486 通过 9 跳过,基线 1484 + 新增 2)/ build
全绿;DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 8/8。
版本 0.24.0 → 0.24.1(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION /
README×2 / CHANGELOG×2 同步)。
This commit is contained in:
@@ -399,6 +399,73 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('re-registers NOTHING on a store change (a folder toggle must not replace the explorer body)', () => {
|
||||
// Regression (reported as "expanding a folder makes the whole tree
|
||||
// refresh"): the surface kept its registrations in step by diffing them on
|
||||
// every service AND store notification, and the built-in `files` takeover
|
||||
// is keyed by its KIND — never by a descriptor id — so the "drop what is
|
||||
// no longer wanted" loop read it as a stray registration and disposed it on
|
||||
// EVERY state write. Disposing and re-creating a slot registration replaces
|
||||
// the host's slot entry, which unmounts the tab body it draws: toggling a
|
||||
// folder tore down the explorer (level cache, scroll position and the
|
||||
// directory watcher all live in that component) and remounted it, so the
|
||||
// tree blanked, re-requested [root, ...expanded] and rebuilt every row.
|
||||
// The store notifies on each toggle, so this churned on every click.
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: () => 'Files', component: () => null })
|
||||
service.registerTab({ id: 'git', title: () => 'Changes', component: () => null })
|
||||
const records = createNativeTabRecords()
|
||||
|
||||
/** Every slot registration, in order (the unregister keys land in `disposed`). */
|
||||
const registered: string[] = []
|
||||
const disposed: string[] = []
|
||||
const registry = {
|
||||
register: (definition: { id: string; kind: string; title: (address: string) => string }) =>
|
||||
() => { disposed.push(definition.id) },
|
||||
}
|
||||
const ctx = {
|
||||
inject: (_deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
|
||||
callback({ get: () => registry })
|
||||
return { dispose: () => { /* the surface owns the rest */ } }
|
||||
},
|
||||
get: () => registry,
|
||||
slots: {
|
||||
inject: (_key: string, callback: () => () => void) => callback(),
|
||||
register: (options: { name: string; key?: string }) => {
|
||||
const key = options.key ?? options.name
|
||||
registered.push(key)
|
||||
return () => { disposed.push(key) }
|
||||
},
|
||||
},
|
||||
}
|
||||
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
|
||||
const mounted = [...registered]
|
||||
/** The takeover's slot registrations: its body + its chip title, one pair. */
|
||||
const filesSlots = (): number => registered.filter(key => key === 'dsh-better-sidebar:files').length
|
||||
expect(filesSlots(), 'the takeover registers its body + chip slots').toBe(2)
|
||||
expect(disposed).toEqual([])
|
||||
|
||||
// A folder toggle: only state, no registry and no settings change. The
|
||||
// native surface must not touch a single registration.
|
||||
store.reduce(state => toggleExpanded(state, '/work/big'))
|
||||
store.reduce(state => toggleExpanded(state, '/work/big/child'))
|
||||
// Any other state write behaves the same (the workbench height, a tab
|
||||
// switch, the selection — all of them notify the same subscribers).
|
||||
store.update(state => { state.bottomOpen = !state.bottomOpen })
|
||||
expect({ registered, disposed }).toEqual({ registered: mounted, disposed: [] })
|
||||
|
||||
// The takeover still follows the editor type's own switch: disabling the
|
||||
// editor releases it, re-enabling brings it back exactly once.
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
|
||||
expect(disposed).toContain('dsh-better-sidebar:files')
|
||||
expect(registered).toEqual(mounted)
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: true } })
|
||||
expect(filesSlots(), 'the re-enabled takeover registers again — exactly one pair').toBe(4)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('claims exactly the extensions DSH has no preview for (the nine restored formats included)', () => {
|
||||
// DSH 0.1.7 handed every read-only preview to `ui-sidebar-documentpreview`,
|
||||
// and `canOpen` returning false is what gives the address away. The host's
|
||||
|
||||
Reference in New Issue
Block a user