在 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 菜单标题同步。
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
/**
|
|
* The unified changes tab shell: the lens switcher swaps the Git lens for
|
|
* the session lens, and a Git-lens file row opens the shared preview pane
|
|
* (loaded through the mocked git API, rendered by the shared DiffFiles
|
|
* stack) instead of minting a diff tab directly.
|
|
*/
|
|
// @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 { ChangesTab } from '../src/client/changes/ChangesTab.tsx'
|
|
import { createSidebarStore } from '../src/client/state.ts'
|
|
import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
|
|
import type { Context } from '../src/context-types.ts'
|
|
import type { SidebarTab } from '../src/client/state.ts'
|
|
|
|
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
|
|
const MAIN = 'C:/repo/main'
|
|
|
|
function fakeContext(): Context {
|
|
return {
|
|
sessions: { get: () => undefined },
|
|
get: () => undefined,
|
|
on: () => () => { /* no-op */ },
|
|
} as unknown as Context
|
|
}
|
|
|
|
function mount(root: Root): void {
|
|
const tab: SidebarTab = { id: 'git', type: 'git', title: 'Changes' }
|
|
act(() => {
|
|
root.render(createElement(ChangesTab, {
|
|
ctx: fakeContext(),
|
|
store: createSidebarStore(),
|
|
scope: { sessionId: 'session', cwd: MAIN },
|
|
tab,
|
|
visible: false,
|
|
onOpenFile: () => {},
|
|
onOpenDiff: () => {},
|
|
}))
|
|
})
|
|
}
|
|
|
|
function mockGit(entries: Array<{ path: string; xy: string }>): void {
|
|
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([
|
|
{ path: MAIN, branch: 'main', current: true, changes: entries.length },
|
|
] as GitWorktree[])
|
|
vi.spyOn(api, 'gitStatus').mockResolvedValue({
|
|
isRepo: true, branch: 'main', entries,
|
|
} as GitStatusResult)
|
|
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
|
|
vi.spyOn(api, 'gitLog').mockResolvedValue([])
|
|
}
|
|
|
|
async function flushEffects(): Promise<void> {
|
|
await act(async () => { await Promise.resolve() })
|
|
await act(async () => { await Promise.resolve() })
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('ChangesTab', () => {
|
|
it('renders the git lens by default and previews a file row in the pane', async () => {
|
|
mockGit([{ path: 'src/a.ts', xy: ' M' }])
|
|
vi.spyOn(api, 'gitDiff').mockResolvedValue({
|
|
diff: [
|
|
'diff --git a/src/a.ts b/src/a.ts',
|
|
'--- a/src/a.ts',
|
|
'+++ b/src/a.ts',
|
|
'@@ -1 +1 @@',
|
|
'-old',
|
|
'+new',
|
|
].join('\n'),
|
|
})
|
|
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const root: Root = createRoot(container)
|
|
try {
|
|
mount(root)
|
|
await flushEffects()
|
|
|
|
// Git lens: the file row from the mocked status (badge letter + path).
|
|
const row = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
|
.find(button => button.textContent?.includes('src/a.ts'))
|
|
expect(row).toBeDefined()
|
|
|
|
// Clicking the row previews inline (no diff tab minted): the shared
|
|
// renderer draws the added row.
|
|
await act(async () => { row!.click() })
|
|
await flushEffects()
|
|
expect(container.textContent).toContain('new')
|
|
|
|
// The preview survives a lens switch (it is a dock, not lens state).
|
|
const group = container.querySelector('[role="group"]')
|
|
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
|
|
.find(button => button.getAttribute('aria-pressed') === 'false')
|
|
expect(sessionButton).toBeDefined()
|
|
await act(async () => { sessionButton!.click() })
|
|
expect(container.textContent).toContain('src/a.ts')
|
|
} finally {
|
|
act(() => { root.unmount() })
|
|
container.remove()
|
|
}
|
|
})
|
|
|
|
it('switches to the session lens, which shows its empty state without ops', async () => {
|
|
mockGit([])
|
|
const container = document.createElement('div')
|
|
document.body.append(container)
|
|
const root: Root = createRoot(container)
|
|
try {
|
|
mount(root)
|
|
await flushEffects()
|
|
const group = container.querySelector('[role="group"]')
|
|
expect(group).not.toBeNull()
|
|
const buttons = [...group!.querySelectorAll<HTMLButtonElement>('button')]
|
|
await act(async () => { buttons[1]!.click() })
|
|
// No session events in the fake context: the session lens empty state
|
|
// (locale-agnostic — the test env may resolve zh or en).
|
|
expect(container.textContent).toMatch(/本会话还没有文件操作|No file operations/)
|
|
// The git lens (its branch picker) is unmounted by the switch.
|
|
expect(container.querySelector('select')).toBeNull()
|
|
} finally {
|
|
act(() => { root.unmount() })
|
|
container.remove()
|
|
}
|
|
})
|
|
})
|