在 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 菜单标题同步。
116 lines
4.9 KiB
TypeScript
116 lines
4.9 KiB
TypeScript
/**
|
|
* The diff tab: one change opened from the changes tab, 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 changes tab's staging/discard operations change the
|
|
* very content it shows. Rendering goes through the shared {@link DiffFiles}
|
|
* renderer — the same one the changes tab's inline preview uses.
|
|
*/
|
|
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 { DiffFiles } from './diff/DiffFiles.tsx'
|
|
import { t } from './locales.ts'
|
|
import { resolveSidebarPath } from './produced-files.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, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
|
|
setLoading(true)
|
|
setError(null)
|
|
setData(null)
|
|
const load = async (): Promise<void> => {
|
|
try {
|
|
if (diff.kind === 'commit') {
|
|
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
|
|
if (!cancelled) setData({ diff: result.diff })
|
|
return
|
|
}
|
|
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
|
|
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, diff.worktree)
|
|
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) {
|
|
// A child-repo path is relative to diff.repoRoot, not the session
|
|
// cwd or the linked-worktree root; resolve against whichever the
|
|
// diff ref carries so the untracked fallback reads the right file.
|
|
const text = await api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, 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 size={14} />
|
|
</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
|
|
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
|
: <DiffFiles diff={data.diff} />}
|
|
{data.diff === '' && data.untracked === undefined && (
|
|
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|