✨ feat(vscode): 「改动」tab 重做为整 PR 聚合的主从式内联 diff(git-diff-view)
- 左树右 diff:左栏全 PR 文件树 + 统计条(共改 N · +X−Y · 已查看 M/N),右栏选中单文件内联 diff - 用 @git-diff-view/react 只渲染当前选中文件,绕开上万行全量渲染的性能坑 - gitea 新增 listPullRequestFiles、getPullRequest 取 merge_base/head,按需回两版原文 - diff 走 merge_base→head 三点口径(与 Gitea 网页一致),getRawFile 404 兜底成全增/全删/改名 - 已查看态按 head sha 落盘、新 push 自然重置;去掉提交列表、原生 diff 与 spx-gitea 虚拟文档 provider
This commit is contained in:
1 parent
ceea62ed16
commit
fb505d59c5
18 files changed
+1149
-1127
No files matched your search
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 「改动」面板右栏:把选中单个文件改动前后的原文交给 git-diff-view 内联渲染
|
||||
* (主从式,只渲染当前一个文件,故上万行 PR 也不卡)。
|
||||
*
|
||||
* - 左右内容来自 merge_base / head 两版原文;diff 由库在 webview 端算。
|
||||
* - 主题跟随 VS Code(body 的 vscode-light/dark class),MutationObserver 监听切换。
|
||||
* - 语法高亮用库自带(diffViewHighlight),色彩取库的 light/dark 主题——不会和 VS Code
|
||||
* 主题 100% 一致,是本方案已知且可接受的小瑕疵。
|
||||
*/
|
||||
|
||||
import type { FileDiff } from '../../hooks/usePrFiles'
|
||||
import { generateDiffFile } from '@git-diff-view/file'
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { statusBadge, statusColorVar } from './fileTree'
|
||||
import '@git-diff-view/react/styles/diff-view.css'
|
||||
|
||||
interface FileDiffPaneProps {
|
||||
/** 选中文件路径;undefined 表示尚未选中。 */
|
||||
path: string | undefined
|
||||
/** 选中文件状态(A/M/D/R…),仅用于右栏标题徽标。 */
|
||||
status: string | undefined
|
||||
/** 该文件的 diff 内容;undefined 表示请求中(加载态)。 */
|
||||
diff: FileDiff | undefined
|
||||
}
|
||||
|
||||
/** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */
|
||||
function readThemeKind(): 'light' | 'dark' {
|
||||
if (typeof document === 'undefined')
|
||||
return 'dark'
|
||||
const cl = document.body.classList
|
||||
if (cl.contains('vscode-light') || cl.contains('vscode-high-contrast-light'))
|
||||
return 'light'
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileDiffPane({ path, status, diff }: FileDiffPaneProps) {
|
||||
const [mode, setMode] = useState<DiffModeEnum>(DiffModeEnum.Split)
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind())
|
||||
|
||||
// 主题随 VS Code 实时切换。
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => setTheme(readThemeKind()))
|
||||
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
// 由两版原文构建并预处理 DiffFile;path/内容/模式/主题任一变就重建。
|
||||
// oldContent === newContent(二进制或纯改名)时不出 diff,留空态由下方分支处理。
|
||||
const diffFile = useMemo(() => {
|
||||
if (!path || !diff || diff.error)
|
||||
return null
|
||||
if (diff.oldContent === diff.newContent)
|
||||
return null
|
||||
const file = generateDiffFile(
|
||||
path,
|
||||
diff.oldContent,
|
||||
path,
|
||||
diff.newContent,
|
||||
diff.oldLang ?? '',
|
||||
diff.newLang ?? '',
|
||||
)
|
||||
file.initTheme(theme)
|
||||
file.init()
|
||||
if (mode === DiffModeEnum.Unified)
|
||||
file.buildUnifiedDiffLines()
|
||||
else
|
||||
file.buildSplitDiffLines()
|
||||
return file
|
||||
}, [path, diff, mode, theme])
|
||||
|
||||
if (!path)
|
||||
return <Centered>选择左侧文件查看改动</Centered>
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 右栏标题条:文件路径 + 状态徽标 + Split/Unified 切换 */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--vscode-panel-border)] px-2 py-1">
|
||||
{status && (
|
||||
<span
|
||||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||
style={{ color: statusColorVar(status) }}
|
||||
>
|
||||
{statusBadge(status)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--vscode-foreground)]" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<div className="flex shrink-0 overflow-hidden rounded border border-[var(--vscode-panel-border)] text-[10px]">
|
||||
{([['分栏', DiffModeEnum.Split], ['统一', DiffModeEnum.Unified]] as const).map(([label, m]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-1.5 py-0.5 transition-colors ${
|
||||
mode === m
|
||||
? 'bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)]'
|
||||
: 'text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--vscode-editor-background)] text-xs">
|
||||
{diff === undefined
|
||||
? <Centered>加载中…</Centered>
|
||||
: diff.error
|
||||
? (
|
||||
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">{diff.error}</div>
|
||||
)
|
||||
: diffFile === null
|
||||
? <Centered>无文本差异(可能是二进制文件或纯重命名)</Centered>
|
||||
: (
|
||||
<DiffView
|
||||
diffFile={diffFile}
|
||||
diffViewMode={mode}
|
||||
diffViewTheme={theme}
|
||||
diffViewHighlight
|
||||
diffViewFontSize={12}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 「改动」面板左栏的文件树:目录可折叠、单子目录链合并,文件行点击即选中(驱动右栏
|
||||
* diff),行尾「已查看」勾可单独切换;目录勾 = 其下可见文件全已查看,点击级联整目录。
|
||||
*
|
||||
* 纯展示 + 回调:折叠态/选中态/已确认集合都由容器持有,本组件只发语义回调。
|
||||
*/
|
||||
|
||||
import type { TreeRow } from './fileTree'
|
||||
import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { statusBadge, statusColorVar } from './fileTree'
|
||||
|
||||
interface FileTreeProps {
|
||||
rows: TreeRow[]
|
||||
selectedPath: string | undefined
|
||||
confirmed: ReadonlySet<string>
|
||||
onToggleCollapse: (path: string) => void
|
||||
onSelectFile: (path: string) => void
|
||||
/** 切换单个文件的已查看态,next 为切换后应处于的状态。 */
|
||||
onToggleFileConfirm: (path: string, next: boolean) => void
|
||||
/** 切换整个目录(其下全部可见文件)的已查看态,next 为切换后应处于的状态。 */
|
||||
onToggleDirConfirm: (descendantFiles: string[], next: boolean) => void
|
||||
}
|
||||
|
||||
export function FileTree({
|
||||
rows,
|
||||
selectedPath,
|
||||
confirmed,
|
||||
onToggleCollapse,
|
||||
onSelectFile,
|
||||
onToggleFileConfirm,
|
||||
onToggleDirConfirm,
|
||||
}: FileTreeProps) {
|
||||
// JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover),
|
||||
// Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。
|
||||
const [hoveredPath, setHoveredPath] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col py-0.5">
|
||||
{rows.map((row) => {
|
||||
const indentStyle = { paddingLeft: row.depth * 12 + 8 }
|
||||
if (row.kind === 'dir') {
|
||||
// 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。
|
||||
const isConfirmed
|
||||
= row.descendantFiles.length > 0
|
||||
&& row.descendantFiles.every(p => confirmed.has(p))
|
||||
return (
|
||||
// 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。
|
||||
<li
|
||||
key={row.path}
|
||||
onMouseEnter={() => setHoveredPath(row.path)}
|
||||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||||
onClick={() => onToggleCollapse(row.path)}
|
||||
className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${
|
||||
isConfirmed ? 'opacity-60' : ''
|
||||
}`}
|
||||
style={indentStyle}
|
||||
>
|
||||
{row.collapsed
|
||||
? <ChevronRight className="size-3.5 shrink-0" />
|
||||
: <ChevronDown className="size-3.5 shrink-0" />}
|
||||
<Folder
|
||||
className="size-3.5 shrink-0"
|
||||
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
||||
/>
|
||||
<span className="min-w-0 truncate">{row.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleDirConfirm(row.descendantFiles, !isConfirmed)
|
||||
}}
|
||||
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
|
||||
className="shrink-0 transition-opacity"
|
||||
style={{
|
||||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||||
color: isConfirmed
|
||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||
: 'var(--vscode-descriptionForeground)',
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const isConfirmed = confirmed.has(row.path)
|
||||
const isSelected = selectedPath === row.path
|
||||
return (
|
||||
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。
|
||||
<li
|
||||
key={row.path}
|
||||
onMouseEnter={() => setHoveredPath(row.path)}
|
||||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||||
onClick={() => onSelectFile(row.path)}
|
||||
title={`${row.status} · ${row.path}`}
|
||||
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 ${
|
||||
isSelected
|
||||
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
|
||||
: 'hover:bg-[var(--vscode-list-hoverBackground)]'
|
||||
} ${isConfirmed ? 'opacity-60' : ''}`}
|
||||
style={indentStyle}
|
||||
>
|
||||
<span
|
||||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||||
style={{ color: statusColorVar(row.status) }}
|
||||
>
|
||||
{statusBadge(row.status)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs">{row.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFileConfirm(row.path, !isConfirmed)
|
||||
}}
|
||||
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
|
||||
className="shrink-0 transition-opacity"
|
||||
style={{
|
||||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||||
color: isConfirmed
|
||||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||
: 'var(--vscode-descriptionForeground)',
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 「改动」文件树的纯函数层:把 `{ path, status }[]` 构建成目录树并拍平成可渲染行,
|
||||
* 外加状态徽标/颜色/忽略路径等小工具。无 React 依赖,便于单测与复用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 拍平后的可渲染行:
|
||||
* - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、
|
||||
* 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联);
|
||||
* - 文件行额外带完整路径与状态。
|
||||
*/
|
||||
export type TreeRow
|
||||
= | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] }
|
||||
| { kind: 'file', name: string, depth: number, path: string, status: string }
|
||||
|
||||
interface FileLeaf { name: string, path: string, status: string }
|
||||
|
||||
/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */
|
||||
interface DirNode {
|
||||
dirs: Map<string, DirNode>
|
||||
files: FileLeaf[]
|
||||
}
|
||||
|
||||
function newDirNode(): DirNode {
|
||||
return { dirs: new Map(), files: [] }
|
||||
}
|
||||
|
||||
/** 状态首字母徽标(大写),未知状态取首字母兜底。 */
|
||||
export function statusBadge(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s.startsWith('add'))
|
||||
return 'A'
|
||||
if (s.startsWith('modif'))
|
||||
return 'M'
|
||||
if (s.startsWith('delet') || s.startsWith('remov'))
|
||||
return 'D'
|
||||
if (s.startsWith('renam'))
|
||||
return 'R'
|
||||
if (s.startsWith('cop'))
|
||||
return 'C'
|
||||
return (status[0] ?? '?').toUpperCase()
|
||||
}
|
||||
|
||||
/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */
|
||||
export function statusColorVar(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s.startsWith('add') || s.startsWith('cop'))
|
||||
return 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||||
if (s.startsWith('delet') || s.startsWith('remov'))
|
||||
return 'var(--vscode-gitDecoration-deletedResourceForeground)'
|
||||
if (s.startsWith('renam'))
|
||||
return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))'
|
||||
return 'var(--vscode-gitDecoration-modifiedResourceForeground)'
|
||||
}
|
||||
|
||||
/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */
|
||||
export function isIgnoredPath(path: string): boolean {
|
||||
return path.split('/').some(seg => seg === 'tests')
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。
|
||||
*
|
||||
* 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。
|
||||
*/
|
||||
export function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode {
|
||||
const root = newDirNode()
|
||||
for (const f of files) {
|
||||
const segs = f.path.split('/')
|
||||
const fileName = segs[segs.length - 1]
|
||||
let node = root
|
||||
for (let i = 0; i < segs.length - 1; i++) {
|
||||
const seg = segs[i]
|
||||
let child = node.dirs.get(seg)
|
||||
if (!child) {
|
||||
child = newDirNode()
|
||||
node.dirs.set(seg, child)
|
||||
}
|
||||
node = child
|
||||
}
|
||||
node.files.push({ name: fileName, path: f.path, status: f.status })
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */
|
||||
function collectDescendantFiles(node: DirNode): string[] {
|
||||
const out: string[] = []
|
||||
const walk = (n: DirNode) => {
|
||||
for (const f of n.files)
|
||||
out.push(f.path)
|
||||
for (const child of n.dirs.values())
|
||||
walk(child)
|
||||
}
|
||||
walk(node)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* DFS 拍平目录树成可渲染行列表,并感知折叠状态。
|
||||
*
|
||||
* - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时,
|
||||
* 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径
|
||||
* (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。
|
||||
* - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。
|
||||
* - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。
|
||||
*/
|
||||
export function flattenTree(root: DirNode, collapsed: ReadonlySet<string>): TreeRow[] {
|
||||
const rows: TreeRow[] = []
|
||||
// prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。
|
||||
const emit = (node: DirNode, depth: number, prefix: string) => {
|
||||
const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b))
|
||||
for (const name of dirNames) {
|
||||
// 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来;
|
||||
// path 同步累计每个被合并的段,作为折叠状态的唯一标识。
|
||||
let label = name
|
||||
let path = prefix + name
|
||||
let cur = node.dirs.get(name)!
|
||||
while (cur.files.length === 0 && cur.dirs.size === 1) {
|
||||
const [childName, childNode] = [...cur.dirs.entries()][0]
|
||||
label += `/${childName}`
|
||||
path += `/${childName}`
|
||||
cur = childNode
|
||||
}
|
||||
const isCollapsed = collapsed.has(path)
|
||||
rows.push({
|
||||
kind: 'dir',
|
||||
name: label,
|
||||
depth,
|
||||
path,
|
||||
collapsed: isCollapsed,
|
||||
descendantFiles: collectDescendantFiles(cur),
|
||||
})
|
||||
// 折叠则隐藏其下所有内容,不再递归。
|
||||
if (!isCollapsed)
|
||||
emit(cur, depth + 1, `${path}/`)
|
||||
}
|
||||
const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const f of sortedFiles)
|
||||
rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status })
|
||||
}
|
||||
emit(root, 0, '')
|
||||
return rows
|
||||
}
|
||||
Reference in new issue
Block a user