Files
apes-sidebar/tests/file-tree-reveal-scroll.spec.tsx
T
Menghuan1918 36fbc5ac7e feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
2026-09-28 00:27:31 +08:00

126 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* "Show in folder" reveal scrolling: the tree body is the ONLY thing that
* may scroll. The reveal used native scrollIntoView, which scrolls every
* scrollable ancestor — and the clipping fixed panel host
* ([data-dsh-panel-host], overflow hidden at the time) is still
* programmatically scrollable, so a deep reveal shifted the whole panel,
* tab bar included, out of the viewport (reported on DSH 0.1.2-alpha.1).
* The regression guard: body.scrollTo receives the centered, clamped
* target; row.scrollIntoView is never called.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// jsdom defines no scrollIntoView; counting calls needs a real function.
const scrollIntoView = vi.fn()
beforeAll(() => {
;(Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = scrollIntoView
})
afterEach(() => { scrollIntoView.mockClear() })
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [
{ name: 'src', path: '/tmp/src', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
],
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the reveal scroll).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
interface TreeHarness {
/** Re-render with new props; the refresh tick reloads the level cache. */
rerender: (revealed: string[], refreshTick: number) => Promise<void>
body: HTMLElement
/** Pin one row's geometry (jsdom has no layout). */
layoutRow: (top: number, height: number) => void
scrollTo: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(bodyScroll: { top: number; client: number; height: number }): Promise<TreeHarness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (revealed: string[], refreshTick: number): void => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed,
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick,
onUploadRequest: () => {},
busy: false,
}))
}
await act(async () => { render([], 0) })
const body = container.firstElementChild as HTMLElement
const scrollTo = vi.fn()
Object.defineProperty(body, 'scrollTo', { value: scrollTo })
Object.defineProperty(body, 'getBoundingClientRect', { value: () => ({ top: 100, height: bodyScroll.client }) })
Object.defineProperty(body, 'clientHeight', { value: bodyScroll.client })
Object.defineProperty(body, 'scrollHeight', { value: bodyScroll.height })
Object.defineProperty(body, 'scrollTop', { value: bodyScroll.top, writable: true })
return {
rerender: async (revealed, refreshTick) => { await act(async () => { render(revealed, refreshTick) }) },
body,
layoutRow: (top, height) => {
const row = body.querySelector('[data-dsh-revealed]')
expect(row).not.toBeNull()
Object.defineProperty(row as Element, 'getBoundingClientRect', { value: () => ({ top, height }) })
},
scrollTo,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
afterEach(() => { document.body.innerHTML = '' })
describe('FileTree reveal scrolling (show in folder)', () => {
it('centers the revealed row in the tree body only', async () => {
// Body: top 100, 300 tall, scrolled 40; row lands at 400, 20 tall →
// target = 40 + (400 + 10) − (100 + 150) = 200.
const tree = await mountTree({ top: 40, client: 300, height: 1000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(400, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(scrollIntoView).not.toHaveBeenCalled()
expect(tree.scrollTo.mock.calls.map(call => call[0])).toContainEqual({ top: 200, behavior: 'smooth' })
tree.unmount()
})
it('clamps the target to the scrollable range', async () => {
// 5000px-tall scroller with 300 visible: max scrollTop 4700.
const tree = await mountTree({ top: 0, client: 300, height: 5000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(9000, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(tree.scrollTo.mock.calls.map(call => call[0]?.top)).toContainEqual(4700)
tree.unmount()
})
it('scrolls nothing without a reveal', async () => {
const tree = await mountTree({ top: 0, client: 300, height: 1000 })
await tree.rerender([], 1)
expect(tree.scrollTo).not.toHaveBeenCalled()
tree.unmount()
})
})