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
+2 -2
View File
@@ -11,7 +11,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 不同 / 仅左 / 仅右四种状态、字节级内容判定(超大文件采样近似判定并标注)、统计徽章与仅看差异过滤;双击任一文件进入单文件对比,一键返回文件夹列表 - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 不同 / 仅左 / 仅右四种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、统计徽章与仅看差异过滤;双击任一文件进入单文件对比,一键返回文件夹列表
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
@@ -108,7 +108,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.5 - 版本:0.6.1
--- ---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.6.0`** 当前基线版本:**`0.6.1`**
--- ---
@@ -76,10 +76,11 @@ z = 补丁版本号(Patch
0.5.3 ← 稳健性补漏与偏好记忆收尾(已发布):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main 0.5.3 ← 稳健性补漏与偏好记忆收尾(已发布):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main
0.5.4 ← 比较选项联动可视化与状态透明(已发布):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabledcheckbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明) 0.5.4 ← 比较选项联动可视化与状态透明(已发布):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabledcheckbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明)
0.5.5 ← 字符级对比无界计算卡死修复(已发布):diffChars 携 maxEditLength=3000 封顶 Myers 迭代轮数(编辑距离超限立即放弃并降级行级,根治大字符量+大差异输入 O(ND) 无界计算导致遮罩永久卡死——实测 3 万字符全不同无上限需数十分钟) · 字符量降级阈值 20 万收紧至 3 万(双限配合将最坏耗时锁在约 2 秒,worker 内计算界面不冻结) · 重输入 worker 派发阈值随动收紧(原始字符 7500,同步快路径最坏冻结压至 0.5 秒内) · 降级提示文案改“内容或差异过大”覆盖两种降级原因 · DiffOptions 增内部测试参数 charDiffMaxEdit(测试以小阈值毫秒级构造降级场景) 0.5.5 ← 字符级对比无界计算卡死修复(已发布):diffChars 携 maxEditLength=3000 封顶 Myers 迭代轮数(编辑距离超限立即放弃并降级行级,根治大字符量+大差异输入 O(ND) 无界计算导致遮罩永久卡死——实测 3 万字符全不同无上限需数十分钟) · 字符量降级阈值 20 万收紧至 3 万(双限配合将最坏耗时锁在约 2 秒,worker 内计算界面不冻结) · 重输入 worker 派发阈值随动收紧(原始字符 7500,同步快路径最坏冻结压至 0.5 秒内) · 降级提示文案改“内容或差异过大”覆盖两种降级原因 · DiffOptions 增内部测试参数 charDiffMaxEdit(测试以小阈值毫秒级构造降级场景)
0.6.0 ← 解码 worker 化 · Playwright E2E · 文件夹对比(当前): 0.6.0 ← 解码 worker 化 · Playwright E2E · 文件夹对比(已发布):
- 主进程解码 worker 化:≥256KB 缓冲移入 worker_threads 后台解码(GBK 纯 JS 解码大文件可达数百毫秒,不再阻塞主进程事件循环) · 解码与编码探测抽为纯逻辑模块 decode.ts(Uint8Array 入参,主进程同步路径与 worker 克隆路径共用) · worker 单例管理(崩溃拒绝在途任务并销毁重建,创建/运行失败回退主进程同步解码,行为不降级) · 构建改双入口(index + decodeWorkericonv-lite 打包进产物) · out/main 整体 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程,unpacked 路径下无法解析 asar 内 node_modules - 主进程解码 worker 化:≥256KB 缓冲移入 worker_threads 后台解码(GBK 纯 JS 解码大文件可达数百毫秒,不再阻塞主进程事件循环) · 解码与编码探测抽为纯逻辑模块 decode.ts(Uint8Array 入参,主进程同步路径与 worker 克隆路径共用) · worker 单例管理(崩溃拒绝在途任务并销毁重建,创建/运行失败回退主进程同步解码,行为不降级) · 构建改双入口(index + decodeWorkericonv-lite 打包进产物) · out/main 整体 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程,unpacked 路径下无法解析 asar 内 node_modules
- Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stubshowOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist - Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stubshowOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致) - 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
0.6.1 ← 文件夹对比收尾打磨(当前):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+3 -2
View File
@@ -51,7 +51,7 @@ src/main/
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似) ├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似) ├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
@@ -130,7 +130,7 @@ src/renderer/src/
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 「返回文件对比」清空文件夹状态回单文件空态 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比)
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts` ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -156,6 +156,7 @@ src/renderer/src/
- 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null) - 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null)
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different - 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关(失败整体抛出由 Promise.all 语义保证,与串行实现一致,不单测)
### 5.10 文件夹视图(`FolderView.tsx` ### 5.10 文件夹视图(`FolderView.tsx`
- 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数) - 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.6.0", "version": "0.6.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+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[] entries: FolderEntry[]
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */ /** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
truncated: boolean truncated: boolean
/** 枚举发现的文件总数(截断时为上限值 */ /** 枚举发现的文件总数(含被截断未比对的文件,始终为真实总数 */
total: number total: number
} }
@@ -43,6 +43,13 @@ export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
/** 近似判定的采样头部字节数 */ /** 近似判定的采样头部字节数 */
const SAMPLE_BYTES = 8 * 1024 const SAMPLE_BYTES = 8 * 1024
/**
* 内容比对的受控并发数:单侧缺失/大小不同直接判定(无 IO),
* 大小一致的条目按此并发分批读取比对(批内 Promise.all,批间串行)。
* 大目录下避免逐文件串行等待 IO;批内左右两文件也并行,实际排队 IO 约 2 倍并发。
*/
export const SCAN_CONCURRENCY = 16
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */ /** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
async function listFiles( async function listFiles(
root: string, root: string,
@@ -85,7 +92,8 @@ async function readHead(path: string, bytes: number): Promise<Buffer> {
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。 * 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小不同 → different * 判定规则:单侧缺失 → only;大小不同 → different
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。 * 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
* 目录不存在/不可读时抛出异常,由 IPC 调用方转为错误结果。 * 大小一致的条目按 SCAN_CONCURRENCY 受控并发读取比对(输出与顺序和串行实现完全一致,
* 任一文件读取失败仍整体抛出,由 IPC 调用方转为错误结果)。
*/ */
export async function scanFolders( export async function scanFolders(
leftDir: string, leftDir: string,
@@ -98,34 +106,50 @@ export async function scanFolders(
const right = await listFiles(rightDir, maxFiles) const right = await listFiles(rightDir, maxFiles)
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort() const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
const entries: FolderEntry[] = [] // 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行
for (const rel of rels) { 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 lSize = left.files.get(rel)
const rSize = right.files.get(rel) const rSize = right.files.get(rel)
if (lSize === undefined) { 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) { } 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) { } 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) { } else if (lSize <= maxContentBytes) {
const lBuf = await readFile(join(leftDir, rel)) tasks.push({
const rBuf = await readFile(join(rightDir, rel)) idx,
entries.push( run: async () => {
lBuf.equals(rBuf) const [lBuf, rBuf] = await Promise.all([
? { rel, status: 'same', leftSize: lSize, rightSize: rSize } readFile(join(leftDir, rel)),
: { rel, status: 'different', leftSize: lSize, rightSize: rSize } readFile(join(rightDir, rel))
) ])
entries[idx] = lBuf.equals(rBuf)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
}
})
} else { } else {
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注 // 超过全量比对上限:头部采样近似判定,结果带 approximate 标注
const lHead = await readHead(join(leftDir, rel), SAMPLE_BYTES) tasks.push({
const rHead = await readHead(join(rightDir, rel), SAMPLE_BYTES) idx,
entries.push( run: async () => {
lHead.equals(rHead) const [lHead, rHead] = await Promise.all([
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true } readHead(join(leftDir, rel), SAMPLE_BYTES),
: { rel, status: 'different', leftSize: lSize, rightSize: rSize } 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) } 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 return
} }
if (!rightDir) return if (!rightDir) return
// 同目录防呆:两侧相同只能得到"全部一致"的无意义结果
if (leftDir === rightDir) {
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
return
}
await runFolderScan(leftDir, rightDir) await runFolderScan(leftDir, rightDir)
}, [showToast, runFolderScan]) }, [showToast, runFolderScan])
@@ -402,9 +407,11 @@ export default function App(): ReactElement {
setMode('folder') setMode('folder')
}, []) }, [])
/** 退出文件夹对比(清空文件夹状态,回到单文件空态) */ /** 退出文件夹对比(清空文件夹状态与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => { const exitFolder = useCallback((): void => {
setFolderState(null) setFolderState(null)
setPaneL(null)
setPaneR(null)
setMode('file') setMode('file')
}, []) }, [])
+35
View File
@@ -1092,4 +1092,39 @@ describe('App - 文件夹对比', () => {
// header 不再出现返回文件夹按钮(文件夹状态已清空) // header 不再出现返回文件夹按钮(文件夹状态已清空)
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument() 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()
})
}) })