/** * 「改动」文件树的纯函数层:把 `{ 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 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): 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 }