文件页(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)
185 lines
6.5 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|