feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚

This commit is contained in:
2026-08-18 18:40:03 +08:00
parent 74d52ec0b0
commit 1c52f5d3cb
25 changed files with 2703 additions and 281 deletions
+103 -27
View File
@@ -8,7 +8,13 @@ import { join } from 'path'
*/
/** 单个文件条目的对比状态 */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
export type FolderEntryStatus =
| 'same'
| 'semantic-same'
| 'different'
| 'left-only'
| 'right-only'
| 'unreadable'
export interface FolderEntry {
/** 相对路径(统一 / 分隔,含子目录前缀) */
@@ -36,6 +42,34 @@ export interface ScanOptions {
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
@@ -66,7 +100,13 @@ async function listFiles(
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
while (queue.length > 0) {
const { dir, prefix } = queue.shift()!
const dirents = await readdir(dir, { withFileTypes: true })
// 单个目录不可读(权限/被锁):跳过该目录继续扫描其余部分,不整体失败
let dirents
try {
dirents = await readdir(dir, { withFileTypes: true })
} catch {
continue
}
for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) {
@@ -83,10 +123,14 @@ async function listFiles(
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
if (truncated) break
}
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel))))
// 并行 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) => files.set(rel, stats[i].size))
rels.forEach((rel, i) => {
const s = stats[i]
if (s.status === 'fulfilled') files.set(rel, s.value.size)
})
return { files, truncated, total }
}
@@ -104,10 +148,12 @@ async function readHead(path: string, bytes: number): Promise<Buffer> {
/**
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小不同 → different
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
* 大小一致的条目按 SCAN_CONCURRENCY 受控并发读取比对(输出与顺序和串行实现完全一致,
* 任一文件读取失败仍整体抛出,由 IPC 调用方转为错误结果)。
* 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对
* 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等,
* 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
*/
export async function scanFolders(
leftDir: string,
@@ -116,9 +162,17 @@ export async function scanFolders(
): 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)
@@ -130,38 +184,60 @@ export async function scanFolders(
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) {
} else if (lSize !== rSize && !semanticEligible(rel, lSize, rSize)) {
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
} else if (lSize <= maxContentBytes) {
} else if (lSize === rSize && lSize > maxContentBytes) {
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
const [lBuf, rBuf] = await Promise.all([
readFile(join(leftDir, rel)),
readFile(join(rightDir, rel))
])
entries[idx] = lBuf.equals(rBuf)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
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 {
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注
// 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。
// 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO);
// 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
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 }
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()))
}