feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0

- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图
- 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复)
- 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多)
- 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗
- 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空)
- 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用)
- host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
This commit is contained in:
Menghuan1918
2026-08-10 22:29:24 +08:00
parent feea87557c
commit 069c754877
17 changed files with 1610 additions and 95 deletions
+161 -1
View File
@@ -4,8 +4,10 @@
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve as resolvePath } from 'node:path'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import * as git from '../src/git.ts'
@@ -75,6 +77,31 @@ describe('host plugin smoke', () => {
expect(branches.names).toContain(branches.current)
})
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
const cwd = process.cwd()
const log = await git.log(cwd)
const first = log[0]!
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
expect(typeof first.refs).toBe('string')
const patch = await git.commitDiff(cwd, first.hashFull)
expect(patch).toContain('diff --git')
})
it('pages the log lazily with skip/count', async () => {
const cwd = process.cwd()
const first = await git.log(cwd, 5, 0)
expect(first).toHaveLength(5)
const second = await git.log(cwd, 5, 5)
expect(second).toHaveLength(5)
// The pages are disjoint windows over the same ordered history.
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
const all = await git.log(cwd, 10, 0)
expect(all.slice(0, 5)).toEqual(first)
expect(all.slice(5)).toEqual(second)
// A skip past the end returns an empty page (the lazy loader's stop sign).
expect(await git.log(cwd, 5, 10_000)).toEqual([])
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
@@ -165,6 +192,110 @@ describe('host plugin smoke', () => {
})
})
/**
* Destructive git operations (discard / revert / cherry-pick) run against a
* throwaway repository under the OS temp dir — never the plugin repo. The
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
* environment variables, confined to the fixture process: no git config is
* touched anywhere (the plugin never sets an identity, and neither does its
* test fixture).
*/
describe('git destructive operations (scratch repository)', () => {
const FIXTURE_IDENTITY = {
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
}
const gitRun = (cwd: string, args: string[]): string => {
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
encoding: 'utf8',
env: { ...process.env, ...FIXTURE_IDENTITY },
})
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
}
return result.stdout
}
/** A fresh repo on branch `main` with one committed file `a.txt`. */
const makeScratchRepo = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
gitRun(dir, ['init', '-q'])
gitRun(dir, ['checkout', '-q', '-b', 'main'])
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'base'])
return dir
}
it('discard restores the worktree file from the index (staged changes kept)', async () => {
const dir = makeScratchRepo()
try {
// Unstaged-only changes: fully reverts to the committed content.
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
// Staged changes: the worktree snaps back to the STAGED content and
// the index is untouched (`git checkout -- <path>` restores from the
// index — VSCode's "Discard Changes" semantics).
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
gitRun(dir, ['add', '-A'])
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
const staged = await git.diff(dir, 'a.txt', true)
expect(staged).toContain('-two')
expect(staged).toContain('+CHANGED')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('revert creates a revert commit', async () => {
const dir = makeScratchRepo()
try {
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'change'])
const featureHash = (await git.log(dir))[0]!.hashFull
await git.revert(dir, featureHash)
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('cherry-pick applies a commit from another branch', async () => {
const dir = makeScratchRepo()
try {
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
const featureHash = (await git.log(dir))[0]!.hashFull
gitRun(dir, ['checkout', '-q', 'main'])
await git.cherryPick(dir, featureHash)
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
expect((await git.log(dir))[0]!.subject).toBe('feature work')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a failing destructive operation as a GitCommandError', async () => {
const dir = makeScratchRepo()
try {
// An unknown revision fails before touching anything.
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
@@ -251,6 +382,35 @@ describe('session cwd resolution over the API route', () => {
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
// The plugin repo's status paths are relative to the repo top level
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
// still load per-file diffs instead of failing with "not an absolute
// path". The session header points INTO the repository.
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { diff: string } }
expect(typeof value.value?.diff).toBe('string')
})
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
expect(value.value?.kind).toBe('text')
expect(value.value?.content).toContain('runGit')
})
})
describe('side card settings routes', () => {
+222 -5
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SplitNode,
moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
@@ -89,12 +90,125 @@ describe('git parsing', () => {
])
})
it('parses log rows with unit separators', () => {
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
describe('sidebar state', () => {
@@ -119,6 +233,109 @@ describe('sidebar state', () => {
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
})
const diffTab = (id: string): SidebarTab => ({
id,
type: 'diff',
title: id,
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
})
it('first diff splits the source pane vertically (diff below)', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('split')
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
expect(split.dir).toBe('col')
expect(split.children).toHaveLength(2)
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
expect(split.children[0]!.id).toBe(sourcePane)
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
expect(after.activePane).toBe(split.children[1]!.id)
})
it('reopening the same diff focuses its existing tab', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
// No new panes, no duplicate tabs.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const allTabs = split.children.flatMap(child => child.tabs ?? [])
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
})
it('subsequent diffs stack into the existing diff pane', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
// Still one split: the second diff joins the bottom leaf instead of splitting again.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
expect(diffLeaves).toHaveLength(1)
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
'diff:w:u:src/a.ts',
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
])
})
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
const s = state()
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('leaf')
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
})
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
// The dropped diff tab was the active one: the leaf falls back to a null
// active instead of resetting the whole state.
expect((valid?.splits as { active: string | null }).active).toBeNull()
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
const onlyDiff = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
},
})
expect(onlyDiff?.splits.kind).toBe('leaf')
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })