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:
@@ -3,6 +3,11 @@
|
||||
* 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'
|
||||
@@ -12,12 +17,15 @@ 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 },
|
||||
@@ -25,6 +33,7 @@ const inventories: GitWorktree[] = [
|
||||
]
|
||||
|
||||
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' }] }
|
||||
@@ -51,8 +60,29 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
}
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
// 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() })
|
||||
@@ -71,16 +101,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(GitLens, {
|
||||
scope: { sessionId: 'session', cwd: MAIN },
|
||||
store: createSidebarStore(),
|
||||
onOpenFile: () => {},
|
||||
onPreview: () => {},
|
||||
selectedRef: null,
|
||||
visible: false,
|
||||
}))
|
||||
})
|
||||
mountGit(root)
|
||||
await flushEffects()
|
||||
|
||||
const selects = container.querySelectorAll<HTMLSelectElement>('select')
|
||||
@@ -130,16 +151,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(GitLens, {
|
||||
scope: { sessionId: 'session', cwd: MAIN },
|
||||
store: createSidebarStore(),
|
||||
onOpenFile: () => {},
|
||||
onPreview: () => {},
|
||||
selectedRef: null,
|
||||
visible: false,
|
||||
}))
|
||||
})
|
||||
mountGit(root)
|
||||
await flushEffects()
|
||||
|
||||
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
|
||||
@@ -164,4 +176,248 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user