- 左树右 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
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
/**
|
|
* 「改动」文件树的纯函数层:把 `{ 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
|
|
}
|