- 真正的统一 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 分页
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
/**
|
|
* The real diff surface for the git panel: parses the host's unified diff
|
|
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
|
|
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
|
|
* gutters, and aligned context / deleted / added rows colored through the
|
|
* DSH tokens. Untracked files produce no `git diff` output, so the caller
|
|
* can pass the file content to render as a full-file addition instead.
|
|
*
|
|
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
|
* cases are unit-tested without a DOM.
|
|
*/
|
|
import { useMemo, useState, type ReactNode } from 'react'
|
|
import clsx from 'clsx'
|
|
import { t } from './locales.ts'
|
|
import css from './sidebar.module.css'
|
|
|
|
/** One rendered diff line. */
|
|
export interface DiffLine {
|
|
kind: 'ctx' | 'del' | 'add' | 'meta'
|
|
/** The line content without its diff marker ('' for the no-newline marker). */
|
|
text: string
|
|
/** Old-side line number (null for pure additions / metadata). */
|
|
oldNum: number | null
|
|
/** New-side line number (null for pure deletions / metadata). */
|
|
newNum: number | null
|
|
}
|
|
|
|
/** One parsed hunk. */
|
|
export interface DiffHunk {
|
|
/** The old-side start line (`-a[,b]`). */
|
|
oldStart: number
|
|
/** The new-side start line (`+c[,d]`). */
|
|
newStart: number
|
|
/** The section text after the trailing `@@` (may be empty). */
|
|
header: string
|
|
lines: DiffLine[]
|
|
}
|
|
|
|
/** One parsed file section of a unified diff. */
|
|
export interface DiffFile {
|
|
/** The `---` path verbatim ('/dev/null' for a new file). */
|
|
oldPath: string
|
|
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
|
|
newPath: string
|
|
/** The file changed with binary content: no hunks to draw. */
|
|
binary: boolean
|
|
hunks: DiffHunk[]
|
|
}
|
|
|
|
/** The parsed unified diff. */
|
|
export interface ParsedDiff {
|
|
files: DiffFile[]
|
|
}
|
|
|
|
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
|
|
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
|
|
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
|
|
if (match === null) return null
|
|
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
|
|
}
|
|
|
|
/**
|
|
* Parse `git diff --no-color` output into file sections and hunks. Rows
|
|
* outside a file section (leading noise) and metadata rows between the
|
|
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
|
|
* changes, rename/similarity lines) are skipped; a section that never
|
|
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
|
|
* can still draw its path.
|
|
*/
|
|
export function parseUnifiedDiff(text: string): ParsedDiff {
|
|
const files: DiffFile[] = []
|
|
let current: DiffFile | null = null
|
|
let inHunk = false
|
|
let hunk: DiffHunk | null = null
|
|
let oldNum = 0
|
|
let newNum = 0
|
|
const flushHunk = (): void => {
|
|
if (current !== null && hunk !== null) current.hunks.push(hunk)
|
|
hunk = null
|
|
inHunk = false
|
|
}
|
|
for (const raw of text.split('\n')) {
|
|
if (raw.startsWith('diff --git ')) {
|
|
flushHunk()
|
|
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
|
|
files.push(current)
|
|
continue
|
|
}
|
|
if (current === null) continue
|
|
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
|
|
flushHunk()
|
|
current.binary = true
|
|
continue
|
|
}
|
|
if (raw.startsWith('--- ')) {
|
|
flushHunk()
|
|
current.oldPath = raw.slice(4)
|
|
continue
|
|
}
|
|
if (raw.startsWith('+++ ')) {
|
|
current.newPath = raw.slice(4)
|
|
continue
|
|
}
|
|
const header = parseHunkHeader(raw)
|
|
if (header !== null) {
|
|
flushHunk()
|
|
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
|
|
oldNum = header.oldStart
|
|
newNum = header.newStart
|
|
inHunk = true
|
|
continue
|
|
}
|
|
if (!inHunk || hunk === null) continue
|
|
const marker = raw[0]
|
|
if (marker === '\\') {
|
|
// `\ No newline at end of file`: metadata attached to the previous row.
|
|
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
|
|
continue
|
|
}
|
|
if (marker === ' ') {
|
|
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
|
|
oldNum += 1
|
|
newNum += 1
|
|
} else if (marker === '-') {
|
|
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
|
|
oldNum += 1
|
|
} else if (marker === '+') {
|
|
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
|
|
newNum += 1
|
|
} else {
|
|
// Not a diff line (a hunk can never contain one): stop the hunk.
|
|
flushHunk()
|
|
}
|
|
}
|
|
flushHunk()
|
|
return { files }
|
|
}
|
|
|
|
/** Build the untracked-file shape: one file, one hunk of pure additions. */
|
|
function untrackedFile(path: string, content: string): DiffFile {
|
|
const lines: DiffLine[] = []
|
|
const body = content.endsWith('\n') ? content.slice(0, -1) : content
|
|
if (body !== '') {
|
|
let num = 1
|
|
for (const line of body.split('\n')) {
|
|
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
|
|
num += 1
|
|
}
|
|
}
|
|
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
|
}
|
|
|
|
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
|
function displayPath(path: string): string {
|
|
if (path === '/dev/null') return path
|
|
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
|
return path
|
|
}
|
|
|
|
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
|
function fileTag(file: DiffFile): string | null {
|
|
if (file.binary) return t('diffBinary')
|
|
if (file.oldPath === '/dev/null') return t('diffAdded')
|
|
if (file.newPath === '/dev/null') return t('diffDeleted')
|
|
const oldPath = displayPath(file.oldPath)
|
|
const newPath = displayPath(file.newPath)
|
|
if (oldPath !== newPath) return t('diffRenamed')
|
|
return null
|
|
}
|
|
|
|
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
|
const MAX_DIFF_ROWS = 500
|
|
|
|
export interface DiffViewProps {
|
|
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
|
diff: string
|
|
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
|
untrackedPath?: string
|
|
untrackedContent?: string
|
|
}
|
|
|
|
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
|
|
const parsed = useMemo<ParsedDiff>(() => {
|
|
if (untrackedPath !== undefined) {
|
|
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
|
}
|
|
return parseUnifiedDiff(diff)
|
|
}, [diff, untrackedPath, untrackedContent])
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
// Flatten into display rows so the cap can slice a single list.
|
|
const rows = useMemo(() => {
|
|
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
|
parsed.files.forEach((file, fileIndex) => {
|
|
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
|
if (file.binary) return
|
|
file.hunks.forEach((hunk, hunkIndex) => {
|
|
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
|
hunk.lines.forEach((line, lineIndex) => {
|
|
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
|
})
|
|
})
|
|
})
|
|
return out
|
|
}, [parsed])
|
|
|
|
const hidden = rows.length - MAX_DIFF_ROWS
|
|
const capped = hidden > 0 && !expanded
|
|
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
|
|
const tailLines = MAX_DIFF_ROWS - headLines
|
|
const head = capped ? rows.slice(0, headLines) : rows
|
|
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
|
|
|
if (rows.length === 0) return null
|
|
|
|
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
|
if (row.type === 'path') {
|
|
const tag = fileTag(row.file)
|
|
const from = displayPath(row.file.oldPath)
|
|
const to = displayPath(row.file.newPath)
|
|
return (
|
|
<div key={row.key} className={css.gitDiffFile}>
|
|
<span className={css.gitDiffFilePath}>{to}</span>
|
|
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
|
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
if (row.type === 'hunk') {
|
|
const hunk = row.hunk!
|
|
return (
|
|
<div key={row.key} className={css.gitDiffHunk}>
|
|
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
|
|
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
const line = row.line!
|
|
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
|
return (
|
|
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
|
|
{line.kind === 'meta'
|
|
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
|
: (
|
|
<>
|
|
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
|
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
|
<span className={css.gitDiffCode}>{line.text}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={css.gitDiff}>
|
|
{head.map(renderRow)}
|
|
{hidden > 0 && (
|
|
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
|
|
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
|
|
</button>
|
|
)}
|
|
{tail.map(renderRow)}
|
|
</div>
|
|
)
|
|
}
|