fix: 文件夹对比收尾打磨 - 扫描内容比对受控并发化(16并发分批)、「返回文件对比」清空两侧面板修复旧对比残留、同目录防呆提示、total注释修正与README版本号补同步(0.6.1)

This commit is contained in:
2026-08-18 15:07:02 +08:00
parent be619c2377
commit 946f6b2f6a
8 changed files with 134 additions and 29 deletions
+37
View File
@@ -168,3 +168,40 @@ describe('scanFolders - 边界与选项', () => {
}
})
})
describe('scanFolders - 受控并发内容比对', () => {
it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-conc-r-'))
try {
// 40 个大小一致的条目 > 2×SCAN_CONCURRENCY(16),覆盖至少 3 个并发批次;
// 状态混排:偶数 same / 奇数 different / 尾部两个单侧
const total = 40
for (let i = 0; i < total; i++) {
const same = i % 2 === 0
const content = same ? `c${i}` : `L${i}`
const contentR = same ? `c${i}` : `R${i}`
await writeFile(join(l, `f${String(i).padStart(2, '0')}.txt`), content)
await writeFile(join(r, `f${String(i).padStart(2, '0')}.txt`), contentR)
}
await writeFile(join(l, 'zz-only-left.txt'), 'L')
await writeFile(join(r, 'zz-only-right.txt'), 'R')
const res = await scanFolders(l, r)
expect(res.entries).toHaveLength(total + 2)
// 每个条目状态正确(并发写回槽位不丢不错)
for (let i = 0; i < total; i++) {
const rel = `f${String(i).padStart(2, '0')}.txt`
expect(byRel(res.entries, rel)?.status).toBe(i % 2 === 0 ? 'same' : 'different')
}
expect(byRel(res.entries, 'zz-only-left.txt')?.status).toBe('left-only')
expect(byRel(res.entries, 'zz-only-right.txt')?.status).toBe('right-only')
// 排序仍为字典序(输出顺序与并发执行顺序无关)
const rels = res.entries.map((e) => e.rel)
expect([...rels].sort()).toEqual(rels)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
+45 -21
View File
@@ -27,7 +27,7 @@ export interface ScanResult {
entries: FolderEntry[]
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
truncated: boolean
/** 枚举发现的文件总数(截断时为上限值 */
/** 枚举发现的文件总数(含被截断未比对的文件,始终为真实总数 */
total: number
}
@@ -43,6 +43,13 @@ 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
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
async function listFiles(
root: string,
@@ -85,7 +92,8 @@ async function readHead(path: string, bytes: number): Promise<Buffer> {
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小不同 → different
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
* 目录不存在/不可读时抛出异常,由 IPC 调用方转为错误结果。
* 大小一致的条目按 SCAN_CONCURRENCY 受控并发读取比对(输出与顺序和串行实现完全一致,
* 任一文件读取失败仍整体抛出,由 IPC 调用方转为错误结果)。
*/
export async function scanFolders(
leftDir: string,
@@ -98,34 +106,50 @@ export async function scanFolders(
const right = await listFiles(rightDir, maxFiles)
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
const entries: FolderEntry[] = []
for (const rel of rels) {
// 预分配结果槽位:无 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.push({ rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null })
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null }
} else if (rSize === undefined) {
entries.push({ rel, status: 'left-only', leftSize: lSize, rightSize: null })
entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null }
} else if (lSize !== rSize) {
entries.push({ rel, status: 'different', leftSize: lSize, rightSize: rSize })
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
} else if (lSize <= maxContentBytes) {
const lBuf = await readFile(join(leftDir, rel))
const rBuf = await readFile(join(rightDir, rel))
entries.push(
lBuf.equals(rBuf)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
)
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 }
}
})
} else {
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注
const lHead = await readHead(join(leftDir, rel), SAMPLE_BYTES)
const rHead = await readHead(join(rightDir, rel), SAMPLE_BYTES)
entries.push(
lHead.equals(rHead)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
)
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 }
}
})
}
})
// 受控并发分批执行(批内任一失败整体抛出,与串行实现语义一致)
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) }
}
+8 -1
View File
@@ -357,6 +357,11 @@ export default function App(): ReactElement {
return
}
if (!rightDir) return
// 同目录防呆:两侧相同只能得到"全部一致"的无意义结果
if (leftDir === rightDir) {
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
return
}
await runFolderScan(leftDir, rightDir)
}, [showToast, runFolderScan])
@@ -402,9 +407,11 @@ export default function App(): ReactElement {
setMode('folder')
}, [])
/** 退出文件夹对比(清空文件夹状态,回到单文件空态) */
/** 退出文件夹对比(清空文件夹状态与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => {
setFolderState(null)
setPaneL(null)
setPaneR(null)
setMode('file')
}, [])
+35
View File
@@ -1092,4 +1092,39 @@ describe('App - 文件夹对比', () => {
// header 不再出现返回文件夹按钮(文件夹状态已清空)
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
})
it('先有文件对比再进入文件夹模式:返回文件对比后旧面板内容一并清空', async () => {
// 场景:正在对比 a.txt → 点「对比文件夹」→ 退出:应回到空态而非残留旧对比
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 })
})
render(<App />)
// 先加载左侧文件进入对比态
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
// 进入文件夹模式
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('相同 1')
// 退出文件夹对比:旧文件内容不残留
fireEvent.click(screen.getByText('返回文件对比'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
expect(screen.queryByText(/line1/)).not.toBeInTheDocument()
})
it('两次选择同一文件夹给出防呆提示且不进入文件夹模式', async () => {
window.api = mockApi({
pickFolder: async () => 'C:/same-dir',
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/两次选择的是同一个文件夹/)).toBeInTheDocument()
// 未进入文件夹模式,也未触发扫描
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
})