- 真正的统一 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 分页
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
/**
|
|
* The diff tab: one change opened from the git panel, like VSCode's diff
|
|
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
|
|
* not; untracked files — which git diff never covers — render as a full-file
|
|
* addition from their content), a commit ref loads the commit's full patch
|
|
* (`git.show`-style). The header carries a refresh button because the tab
|
|
* stays mounted while the git panel's staging/discard operations change the
|
|
* very content it shows.
|
|
*/
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { SessionScope } from './api.ts'
|
|
import { api } from './api.ts'
|
|
import type { SidebarDiffRef } from './state.ts'
|
|
import { DiffView } from './DiffView.tsx'
|
|
import { t } from './locales.ts'
|
|
import css from './sidebar.module.css'
|
|
|
|
/** The loaded diff surface (untracked content rendered as a full addition). */
|
|
interface DiffData {
|
|
diff: string
|
|
untracked?: string
|
|
}
|
|
|
|
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
|
|
const { sessionId, cwd, diff } = props
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [data, setData] = useState<DiffData | null>(null)
|
|
const [tick, setTick] = useState(0)
|
|
|
|
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const scope: SessionScope = { sessionId, cwd }
|
|
setLoading(true)
|
|
setError(null)
|
|
setData(null)
|
|
const load = async (): Promise<void> => {
|
|
try {
|
|
if (diff.kind === 'commit') {
|
|
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
|
if (!cancelled) setData({ diff: result.diff })
|
|
return
|
|
}
|
|
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
|
if (result.diff === '') {
|
|
// The requested side is empty — try the OTHER side once: the ref
|
|
// may predate the staged-flag fix, or the change moved sides (a
|
|
// file staged after its tab opened). Both sides empty means the
|
|
// file genuinely has no text changes.
|
|
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
|
if (other.diff !== '') result = other
|
|
}
|
|
if (result.diff !== '') {
|
|
if (!cancelled) setData({ diff: result.diff })
|
|
return
|
|
}
|
|
// Empty diff: an untracked file (git diff never lists it) falls back
|
|
// to a full-file addition; anything else is a genuine no-text-change.
|
|
if (diff.untracked === true && !diff.staged) {
|
|
const text = await api.fsRead(scope, diff.path)
|
|
if (!cancelled) {
|
|
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
|
}
|
|
return
|
|
}
|
|
if (!cancelled) setData({ diff: '' })
|
|
} catch (reason) {
|
|
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
}
|
|
void load()
|
|
return () => { cancelled = true }
|
|
}, [sessionId, cwd, diff, tick])
|
|
|
|
return (
|
|
<div className={css.gitDiffTab}>
|
|
<div className={css.gitDiffTabHeader}>
|
|
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
|
|
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('refresh')}
|
|
title={t('refresh')}
|
|
onClick={refresh}
|
|
>
|
|
<IconRefreshOutline16 />
|
|
</button>
|
|
</div>
|
|
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
|
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
|
|
{!loading && error === null && data !== null && (
|
|
<>
|
|
{data.untracked !== undefined
|
|
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
|
: <DiffView diff={data.diff} />}
|
|
{data.diff === '' && data.untracked === undefined && (
|
|
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|