在 PR #471(file-trace)基础上的演进: - 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角 tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree· 子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件 日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对 时间);file-trace tab 移除(未随任何版本发布)。 - 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/ DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff 共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级 高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文 hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色 色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用 该栈(git diff tab 从此也有高亮)。 - 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再 直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为 独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章 按状态字母着色;选中行高亮。 - i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件 变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。 - 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/ ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式 从 sidebar.module.css 迁至 changes.module.css。 - 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified 转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、 diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原 filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。 - 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单 7 tab、mount e2e 菜单标题同步。
167 lines
6.9 KiB
TypeScript
167 lines
6.9 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.
|
|
*/
|
|
// @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'
|
|
|
|
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
|
|
const MAIN = 'C:/repo/main'
|
|
const AGENT = 'C:/repo/agent'
|
|
|
|
const inventories: GitWorktree[] = [
|
|
{ path: MAIN, branch: 'main', current: true, changes: 0 },
|
|
{ path: AGENT, branch: 'agent', current: false, changes: 1 },
|
|
]
|
|
|
|
function statusFor(target?: string): GitStatusResult {
|
|
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> {
|
|
await act(async () => { await Promise.resolve() })
|
|
await act(async () => { await Promise.resolve() })
|
|
}
|
|
|
|
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 {
|
|
await act(async () => {
|
|
root.render(createElement(GitLens, {
|
|
scope: { sessionId: 'session', cwd: MAIN },
|
|
store: createSidebarStore(),
|
|
onOpenFile: () => {},
|
|
onPreview: () => {},
|
|
selectedRef: null,
|
|
visible: false,
|
|
}))
|
|
})
|
|
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 {
|
|
await act(async () => {
|
|
root.render(createElement(GitLens, {
|
|
scope: { sessionId: 'session', cwd: MAIN },
|
|
store: createSidebarStore(),
|
|
onOpenFile: () => {},
|
|
onPreview: () => {},
|
|
selectedRef: null,
|
|
visible: false,
|
|
}))
|
|
})
|
|
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()
|
|
}
|
|
})
|
|
})
|