Files
apes-sidebar/tests/git-view-worktree.spec.tsx
T
Menghuan1918 b011be1e98 feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
2026-09-28 18:08:21 +08:00

557 lines
24 KiB
TypeScript

/**
* Git worktree selection is an atomic view boundary: status, branch choices
* and history must always come from the same checkout. A stale history row is
* especially dangerous because its revert/cherry-pick action targets the
* currently selected checkout.
*
* The status itself now comes from the shared git-status store (one snapshot
* per session+cwd+worktree, one poller), so these tests observe the selection
* through the rows it renders and through the scope each git call receives —
* including the `repoRoot` a container's worktree listing must carry.
*/
// @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 { GitLens } from '../src/client/changes/GitLens.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import { t } from '../src/client/locales.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
const AGENT = 'C:/repo/agent'
/** A second linked checkout some tests introduce on a later inventory pass. */
const AGENT_2 = 'C:/repo/agent-2'
const inventories: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
{ path: AGENT, branch: 'agent', current: false, changes: 1 },
]
function statusFor(target?: string): GitStatusResult {
if (target === AGENT_2) return { isRepo: true, branch: 'agent-2', entries: [{ path: 'agent-2-change.ts', xy: ' M' }] }
return target === AGENT
? { isRepo: true, branch: 'agent', entries: [{ path: 'agent-change.ts', xy: ' M' }] }
: { isRepo: true, branch: 'main', entries: [{ path: 'main-change.ts', xy: ' M' }] }
}
function logFor(target?: string, index = 0): GitLogEntry[] {
const agent = target === AGENT
const digit = agent ? 'a' : 'b'
const suffix = index.toString(16).padStart(8, '0')
return [{
hash: `${digit.repeat(6)}${suffix.slice(-1)}`,
hashFull: `${digit.repeat(32)}${suffix}`,
subject: agent ? `Agent checkout commit ${index}` : `Main checkout commit ${index}`,
author: 'Test',
date: '2026-08-20 00:00:00 +0800',
refs: agent ? 'HEAD -> agent' : 'HEAD -> main',
}]
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolvePromise!: (value: T) => void
const promise = new Promise<T>((resolve) => { resolvePromise = resolve })
return { promise, resolve: resolvePromise }
}
async function flushEffects(): Promise<void> {
// Each round drains one promise hop: the refresh chain (listing → branch/log
// → state) is several hops deep, and a shallow flush would assert on rows
// that are still in flight.
for (let round = 0; round < 5; round += 1) await act(async () => { await Promise.resolve() })
}
/** Mount one GitLens. Visible by default: a hidden tab owns no live git data
* — the shared status store only polls/loads while a consumer is on screen. */
function mountGit(
root: Root,
options: { scope?: { sessionId: string; cwd?: string }; refreshTick?: number; visible?: boolean } = {},
): void {
act(() => {
root.render(createElement(GitLens, {
scope: options.scope ?? { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: options.visible ?? true,
refreshTick: options.refreshTick ?? 0,
}))
})
}
afterEach(() => { vi.restoreAllMocks() })
describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
it('refreshes status, branches and history together on auto and manual selection', async () => {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
const branch = vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
const log = vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const selects = container.querySelectorAll<HTMLSelectElement>('select')
const worktreeSelect = selects[0]!
// A clean primary + exactly one dirty linked checkout auto-selects the
// linked checkout and loads every target-derived surface from it.
expect(worktreeSelect.value).toBe(AGENT)
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).toContain('Agent checkout commit')
expect(container.textContent).not.toContain('Main checkout commit')
expect(branch).toHaveBeenCalledWith(expect.anything(), AGENT)
expect(log).toHaveBeenCalledWith(expect.anything(), 20, 0, AGENT)
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
expect(worktreeSelect.value).toBe(MAIN)
expect(container.textContent).toContain('main-change.ts')
expect(container.textContent).toContain('Main checkout commit')
expect(container.textContent).not.toContain('Agent checkout commit')
expect(branch).toHaveBeenLastCalledWith(expect.anything(), MAIN)
expect(log).toHaveBeenLastCalledWith(expect.anything(), 20, 0, MAIN)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('drops a late history page when the selected worktree changes', async () => {
const lateAgentPage = deferred<GitLogEntry[]>()
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, skip, target) => {
if (target === AGENT && skip === 20) return lateAgentPage.promise
if (target === AGENT) return Array.from({ length: 20 }, (_value, index) => logFor(AGENT, index)[0]!)
return logFor(MAIN)
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
const loadMore = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => /Load more|加载更多/.test(button.textContent ?? ''))
expect(loadMore).not.toBeUndefined()
await act(async () => { loadMore!.click() })
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
expect(container.textContent).toContain('Main checkout commit 0')
lateAgentPage.resolve(logFor(AGENT, 99))
await flushEffects()
expect(container.textContent).toContain('Main checkout commit 0')
expect(container.textContent).not.toContain('Agent checkout commit 99')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('lists a selected child repository with its repoRoot, running one full refresh', async () => {
const WS = 'C:/ws'
const REPO_A = 'C:/ws/a'
const REPO_B = 'C:/ws/b'
const repoBWorktrees: GitWorktree[] = [
{ path: REPO_B, branch: 'b-main', current: true, changes: 0 },
{ path: 'C:/ws/b-agent', branch: 'b-agent', current: false, changes: 2 },
]
const worktrees = vi.spyOn(api, 'gitWorktrees').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? repoBWorktrees : []
))
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (scope) => (
scope.cwd === REPO_B
? { isRepo: true, branch: 'b-main', entries: [], root: REPO_B, repositories: [REPO_B] }
: { isRepo: true, branch: 'a-main', entries: [], root: REPO_A, repositories: [REPO_A, REPO_B] }
))
vi.spyOn(api, 'gitBranch').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? { current: 'b-main', names: ['b-main'] } : { current: 'a-main', names: ['a-main'] }
))
vi.spyOn(api, 'gitLog').mockResolvedValue([])
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root, { scope: { sessionId: 'session', cwd: WS } })
await flushEffects()
// The workspace container's two child repositories are the repo choices.
const repoSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect([...repoSelect.options].map(option => option.textContent)).toEqual(['a', 'b'])
const listingsBefore = worktrees.mock.calls.length
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(repoSelect, REPO_B)
repoSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
// The listing carries the selected repoRoot — a container's own listing
// is empty, so the child checkout's linked worktrees only appear when the
// selection rides along.
expect(worktrees.mock.calls.at(-1)![0]).toMatchObject({ sessionId: 'session', cwd: WS, repoRoot: REPO_B })
// Exactly ONE full refresh for the switch (not a burst).
expect(worktrees.mock.calls.length).toBe(listingsBefore + 1)
// The shared status follows the same selection.
expect(status.mock.calls.some(([scope]) => scope.cwd === REPO_B)).toBe(true)
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[1]!
expect(worktreeSelect.value).toBe(REPO_B)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('never yanks the view off the auto-selected checkout on a later re-list', async () => {
const LATER: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
{ path: AGENT, branch: 'agent', current: false, changes: 0 },
{ path: AGENT_2, branch: 'agent-2', current: false, changes: 5 },
]
vi.spyOn(api, 'gitWorktrees')
.mockResolvedValueOnce(inventories)
.mockResolvedValue(LATER)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect(worktreeSelect.value).toBe(AGENT)
// A later inventory pass prefers the brand-new dirty checkout; the user
// never chose a checkout, but the ONE automatic pass already ran.
mountGit(root, { refreshTick: 1 })
await flushEffects()
expect(worktreeSelect.value).toBe(AGENT)
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).not.toContain('agent-2-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('queues a manual refresh that lands while a refresh is in flight', async () => {
const held = deferred<GitWorktree[]>()
const worktrees = vi.spyOn(api, 'gitWorktrees')
.mockReturnValueOnce(held.promise)
.mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The header's refresh action lands while the mount pass is in flight.
mountGit(root, { refreshTick: 1 })
await flushEffects()
// Queued, not dropped and not started in parallel.
expect(worktrees).toHaveBeenCalledTimes(1)
held.resolve([])
await flushEffects()
// The queued pass ran and its listing landed: the dirty linked checkout
// is selected and its rows are the ones on screen.
expect(worktrees).toHaveBeenCalledTimes(2)
expect(container.textContent).toContain('agent-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stays quiet while hidden and catches up when the tab becomes visible', async () => {
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
// Mounted in a background pane: no fetch at all (no git processes for a
// tab nobody is looking at).
mountGit(root, { visible: false })
await flushEffects()
expect(worktrees).not.toHaveBeenCalled()
expect(status).not.toHaveBeenCalled()
// Bringing the tab on screen loads the lens through one pass.
mountGit(root, { visible: true })
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
expect(status).toHaveBeenCalled()
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).toContain('Agent checkout commit')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('hands the inventory poll its AbortSignal and stops it when hidden', async () => {
vi.useFakeTimers()
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The 30s inventory cadence fires and carries the poller's signal.
await act(async () => { await vi.advanceTimersByTimeAsync(30_000) })
await flushEffects()
expect(worktrees.mock.calls.length).toBe(2)
const signal = worktrees.mock.calls[1]![1]
expect(signal).toBeInstanceOf(AbortSignal)
// Hiding the tab tears the poll down and aborts its signal: a slow host
// answer can no longer publish state.
mountGit(root, { visible: false })
await flushEffects()
expect((signal as AbortSignal).aborted).toBe(true)
const callsWhileHidden = worktrees.mock.calls.length
await act(async () => { await vi.advanceTimersByTimeAsync(120_000) })
expect(worktrees.mock.calls.length).toBe(callsWhileHidden)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
it('keeps branch switch and history failures out of the commit bar', async () => {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([
{ path: MAIN, branch: 'main', current: true, changes: 1 },
])
vi.spyOn(api, 'gitStatus').mockResolvedValue({
isRepo: true, branch: 'main', entries: [{ path: 'src/a.ts', xy: ' M' }],
})
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main', 'feature'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
vi.spyOn(api, 'gitCheckout').mockRejectedValue(new Error('dirty worktree'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const branchSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(branchSelect, 'feature')
branchSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
const banner = [...container.querySelectorAll<HTMLElement>('[role="alert"]')]
.find(node => (node.textContent ?? '').includes(`${t('checkoutError')}: dirty worktree`))
expect(banner).toBeDefined()
const input = container.querySelector(`input[placeholder="${t('commitPlaceholder')}"]`)
expect(input).not.toBeNull()
// The commit bar owns only its own status line: the branch failure is
// rendered ABOVE it, never under the commit input.
expect(banner!.compareDocumentPosition(input!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
describe('GitLens (changes tab, git lens) change tree', () => {
/** Mount with one changed-file inventory; every test picks its own entries. */
async function mountTree(
container: HTMLElement,
root: Root,
entries: Array<{ path: string; xy: string }>,
): Promise<void> {
const changes = entries.filter(row => row.xy !== ' ').length
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([{ path: MAIN, branch: 'main', current: true, changes }])
vi.spyOn(api, 'gitStatus').mockResolvedValue({ isRepo: true, branch: 'main', entries })
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
mountGit(root)
await flushEffects()
}
function makeRoot(): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.append(container)
return { container, root: createRoot(container) }
}
it('folds a directory row per group, independently of the same path on the other side', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/a.ts', xy: ' M' }, // unstaged side
{ path: 'src/b.ts', xy: 'M ' }, // staged side
])
const dirs = () => [...container.querySelectorAll<HTMLButtonElement>('button[data-dir="src"]')]
// One 'src' row per group: a path with changes on both sides appears in
// both trees.
expect(dirs()).toHaveLength(2)
expect(dirs()[0]!.getAttribute('aria-expanded')).toBe('true')
expect(dirs()[0]!.querySelector('svg')).not.toBeNull()
expect(container.querySelector('button[data-path="src/a.ts"]')).not.toBeNull()
expect(container.querySelector('button[data-path="src/a.ts"]')!.querySelector('svg')).not.toBeNull()
await act(async () => { dirs()[0]!.click() })
// Folded: the unstaged subtree is gone...
expect(dirs()[0]!.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('button[data-path="src/a.ts"]')).toBeNull()
// ...while the staged group's own 'src' row stays open and keeps its file.
expect(dirs()[1]!.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('button[data-path="src/b.ts"]')).not.toBeNull()
await act(async () => { dirs()[0]!.click() })
expect(container.querySelector('button[data-path="src/a.ts"]')).not.toBeNull()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('compresses a single-child directory chain into one labelled row', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/client/changes/a.ts', xy: ' M' },
{ path: 'src/client/changes/b.ts', xy: ' M' },
])
// Three levels, one child each: one row, one label, two changes.
const row = container.querySelector<HTMLButtonElement>('button[data-dir="src/client/changes"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('src/client/changes')
expect(container.querySelector('[data-group="unstaged"] [data-count]')?.textContent).toBe('2')
// Both files hang under that one row.
expect(container.querySelector('button[data-path="src/client/changes/a.ts"]')).not.toBeNull()
expect(container.querySelector('button[data-path="src/client/changes/b.ts"]')).not.toBeNull()
// Nothing on the staged side: only the empty band, no tree rows.
expect(container.querySelectorAll('[data-group="staged"] [data-path]')).toHaveLength(0)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stages a file from a nested tree row and re-reads the shared status', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [{ path: 'src/deep/a.ts', xy: ' M' }])
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
const statusSpy = vi.mocked(api.gitStatus)
const statusCallsBefore = statusSpy.mock.calls.length
const action = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('stage')}"]`)
expect(action).not.toBeNull()
await act(async () => { action!.click() })
await flushEffects()
// The auto-selected primary checkout rides along as the worktree target.
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'src/deep/a.ts', MAIN)
expect(statusSpy.mock.calls.length).toBeGreaterThan(statusCallsBefore)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('bands each group with its count and a labelled stage-all action', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/a.ts', xy: ' M' },
{ path: 'docs/b.md', xy: ' M' },
{ path: 'src/c.ts', xy: 'M ' },
])
const unstaged = container.querySelector<HTMLElement>('[data-group="unstaged"]')!
const staged = container.querySelector<HTMLElement>('[data-group="staged"]')!
expect(unstaged.textContent).toContain(t('unstaged'))
expect(unstaged.querySelector('[data-count]')?.textContent).toBe('2')
expect(staged.querySelector('[data-count]')?.textContent).toBe('1')
const stageAll = unstaged.querySelector<HTMLButtonElement>(`button[aria-label="${t('stageAll')}"]`)
const unstageAll = staged.querySelector<HTMLButtonElement>(`button[aria-label="${t('unstageAll')}"]`)
expect(stageAll).not.toBeNull()
expect(unstageAll).not.toBeNull()
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
await act(async () => { stageAll!.click() })
await flushEffects()
// "Stage all" stages the whole worktree: no path argument.
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), undefined, MAIN)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})