Files
apes-sidebar/tests/diff-git-target.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

185 lines
6.5 KiB
TypeScript

/**
* The shared git-diff loader (`diff/use-git-diff.ts`) sits behind BOTH diff
* surfaces: the changes tab's inline preview pane and the dedicated diff tab.
* These tests drive each surface through the hook — the staged-side fallback,
* the untracked full-addition fallback, and the per-file fold cache two
* sibling folds share — so a future change to one surface cannot silently
* change the other.
*/
// @vitest-environment jsdom
import { afterEach, 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 { DiffPane } from '../src/client/changes/DiffPane.tsx'
import { DiffTab } from '../src/client/DiffTab.tsx'
import { api } from '../src/client/api.ts'
import type { SessionScope } from '../src/client/api.ts'
import type { SidebarDiffRef } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const SCOPE: SessionScope = { sessionId: 's1', cwd: '/p' }
const STAGED_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+staged-side',
].join('\n')
/** A three-hunk patch of one file: two gap folds (lines 2..11 and 13..29). */
const TWO_GAP_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'@@ -12 +12 @@',
'-tail',
'+tail2',
'@@ -30 +30 @@',
'-end',
'+end2',
].join('\n')
const CONTENT = Array.from({ length: 40 }, (_value, index) => `line-${String(index + 1)}`).join('\n')
async function flushEffects(): Promise<void> {
for (let round = 0; round < 4; round += 1) await act(async () => { await Promise.resolve() })
}
afterEach(() => { vi.restoreAllMocks() })
describe('useGitDiffTarget through the inline preview pane (DiffPane)', () => {
it('falls back to the other side when the requested side has no diff', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/a.ts', staged: false }
const gitDiff = vi.spyOn(api, 'gitDiff').mockImplementation(async (_scope, _path, staged) => ({
diff: staged ? STAGED_PATCH : '',
}))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
// The requested (unstaged) side came back empty, so the pane retried the
// staged side and rendered THAT patch.
expect(gitDiff).toHaveBeenNthCalledWith(1, expect.anything(), 'src/a.ts', false, undefined)
expect(gitDiff).toHaveBeenNthCalledWith(2, expect.anything(), 'src/a.ts', true, undefined)
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('renders an untracked file as a full-file addition from its content', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/new.ts', staged: false, untracked: true }
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: '' })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: 'brand-new-line', truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
expect(read).toHaveBeenCalledWith(expect.anything(), '/p/src/new.ts')
expect(container.textContent).toContain('brand-new-line')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
describe('useGitDiffTarget through the dedicated diff tab (DiffTab)', () => {
it('loads a commit patch into the shared renderer', async () => {
vi.spyOn(api, 'gitCommitDiff').mockResolvedValue({ diff: STAGED_PATCH })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'commit', hash: 'abc1234', hashFull: 'a'.repeat(40), subject: 'subject' },
}))
})
await flushEffects()
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('fetches each side of a file ONCE for two sibling fold expansions', async () => {
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: TWO_GAP_PATCH })
const show = vi.spyOn(api, 'gitShow').mockResolvedValue({ content: CONTENT })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: CONTENT, truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
}))
})
await flushEffects()
const firstFold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(firstFold).not.toBeNull()
await act(async () => { firstFold!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-5')
// The second gap is the SAME file: the cache serves it without a fetch.
// (An expanded fold keeps its marker in the DOM, so the second one is
// the second marker in document order.)
const folds = container.querySelectorAll<HTMLElement>('[data-expandable="true"]')
expect(folds).toHaveLength(2)
await act(async () => { folds[1]!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-20')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})