文件页(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)
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
/**
|
||
* "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()
|
||
})
|
||
})
|