Files
DiffLens/src/main/folderScan.ts
T

246 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdir, stat, readFile, open } from 'fs/promises'
import { join } from 'path'
/**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
*/
/** 单个文件条目的对比状态 */
export type FolderEntryStatus =
| 'same'
| 'semantic-same'
| 'different'
| 'left-only'
| 'right-only'
| 'unreadable'
export interface FolderEntry {
/** 相对路径(统一 / 分隔,含子目录前缀) */
rel: string
status: FolderEntryStatus
/** 左侧文件字节数;该侧不存在为 null */
leftSize: number | null
/** 右侧文件字节数;该侧不存在为 null */
rightSize: number | null
/** 超过全量比对上限、仅采样头部判定的近似结果(status 为 same 时可能出现) */
approximate?: boolean
}
export interface ScanResult {
/** 全部条目(按相对路径字典序排序) */
entries: FolderEntry[]
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
truncated: boolean
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
total: number
}
export interface ScanOptions {
/** 单侧文件数量上限(默认 10000) */
maxFiles?: number
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
maxContentBytes?: number
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
semantic?: SemanticOptions
}
/**
* 语义判等的依赖注入(folderScan 保持纯逻辑可测,解码与扩展名判定由调用方注入)。
* 生产环境 decode 走主进程解码 worker(大缓冲不阻塞事件循环),测试注入同步实现。
*/
export interface SemanticOptions {
/** 参与语义判等的单侧文件大小上限 */
maxBytes: number
/** 相对路径是否为文本文件(按扩展名判定,与打开文件对话框的清单对齐) */
isTextFile: (rel: string) => boolean
/** 解码文件字节为文本(BOM/编码探测由注入实现处理) */
decode: (buf: Buffer) => Promise<string>
}
/** 语义判等参与文件的大小上限(2MB):覆盖绝大多数文本源码/配置;超大文件维持字节级判定 */
export const SEMANTIC_MAX_BYTES = 2 * 1024 * 1024
/**
* 语义判等:剔除全部空白(含换行,与字符级对比的判等语义一致)后拼接比较;
* 大小写敏感(大小写差异在代码中多为实质差异,不忽略)。
* 换行符 CRLF/LF、行尾空白、缩进、空行数量、排版重排(含跨行重组)均不构成差异。
*/
export function semanticEquals(left: string, right: string): boolean {
const n = (s: string): string => s.replace(/\s+/g, '')
return n(left) === n(right)
}
export const FOLDER_MAX_FILES = 10000
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
/** 近似判定的采样头部字节数 */
const SAMPLE_BYTES = 8 * 1024
/**
* 内容比对的受控并发数:单侧缺失/大小不同直接判定(无 IO),
* 大小一致的条目按此并发分批读取比对(批内 Promise.all,批间串行)。
* 大目录下避免逐文件串行等待 IO;批内左右两文件也并行,实际排队 IO 约 2 倍并发。
*/
export const SCAN_CONCURRENCY = 16
/**
* 手写 BFS 遍历目录树收集普通文件(相对路径统一 / 分隔,跳过 symlink 防环),
* 凑满 maxFiles 即停止深入:readdir({recursive:true}) 只能枚举完整棵树、无法提前终止,
* 误选超大目录(如含 node_modules)时枚举本身耗时数秒。
* 截断后 total 为已遍历部分的文件数下限(剩余子树不再统计)。
*/
async function listFiles(
root: string,
maxFiles: number
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
const rels: string[] = []
let total = 0
let truncated = false
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
while (queue.length > 0) {
const { dir, prefix } = queue.shift()!
// 单个目录不可读(权限/被锁):跳过该目录继续扫描其余部分,不整体失败
let dirents
try {
dirents = await readdir(dir, { withFileTypes: true })
} catch {
continue
}
for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) {
queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` })
} else if (d.isFile()) {
total++
if (rels.length >= maxFiles) {
truncated = true
} else {
rels.push(prefix === '' ? d.name : `${prefix}/${d.name}`)
}
}
}
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
if (truncated) break
}
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束);
// 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
const files = new Map<string, number>()
rels.forEach((rel, i) => {
const s = stats[i]
if (s.status === 'fulfilled') files.set(rel, s.value.size)
})
return { files, truncated, total }
}
/** 读取文件头部指定字节数(近似判定采样) */
async function readHead(path: string, bytes: number): Promise<Buffer> {
const fh = await open(path, 'r')
try {
const buf = Buffer.alloc(bytes)
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
return buf.subarray(0, bytesRead)
} finally {
await fh.close()
}
}
/**
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对;
* 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等,
* 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
*/
export async function scanFolders(
leftDir: string,
rightDir: string,
options: ScanOptions = {}
): Promise<ScanResult> {
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
const semantic = options.semantic
const left = await listFiles(leftDir, maxFiles)
const right = await listFiles(rightDir, maxFiles)
/** 条目是否参与语义判等:双侧存在、均在大小上限内、文本扩展名 */
const semanticEligible = (rel: string, lSize: number, rSize: number): boolean =>
semantic !== undefined &&
lSize <= semantic.maxBytes &&
rSize <= semantic.maxBytes &&
semantic.isTextFile(rel)
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
// 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行
const entries: FolderEntry[] = new Array(rels.length)
const tasks: { idx: number; run: () => Promise<void> }[] = []
rels.forEach((rel, idx) => {
const lSize = left.files.get(rel)
const rSize = right.files.get(rel)
if (lSize === undefined) {
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null }
} else if (rSize === undefined) {
entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null }
} else if (lSize !== rSize && !semanticEligible(rel, lSize, rSize)) {
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
} else if (lSize === rSize && lSize > maxContentBytes) {
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
try {
const [lHead, rHead] = await Promise.all([
readHead(join(leftDir, rel), SAMPLE_BYTES),
readHead(join(rightDir, rel), SAMPLE_BYTES)
])
entries[idx] = lHead.equals(rHead)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
} catch {
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
}
}
})
} else {
// 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。
// 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO);
// 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
try {
const [lBuf, rBuf] = await Promise.all([
readFile(join(leftDir, rel)),
readFile(join(rightDir, rel))
])
if (lBuf.equals(rBuf)) {
entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize }
return
}
if (semanticEligible(rel, lSize, rSize)) {
const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)])
if (semanticEquals(lt, rt)) {
entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize }
return
}
}
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
} catch {
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
}
}
})
}
})
// 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable
for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
}
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
}