feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚
This commit is contained in:
+103
-27
@@ -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()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user