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)
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user