feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)

This commit is contained in:
2026-08-18 14:40:44 +08:00
parent 2b43b5e676
commit be619c2377
24 changed files with 2030 additions and 151 deletions
+4
View File
@@ -5,6 +5,10 @@ node_modules/
out/
dist/
# E2E
test-results/
playwright-report/
# 系统与编辑器
.DS_Store
Thumbs.db
+9 -2
View File
@@ -11,8 +11,10 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 不同 / 仅左 / 仅右四种状态、字节级内容判定(超大文件采样近似判定并标注)、统计徽章与仅看差异过滤;双击任一文件进入单文件对比,一键返回文件夹列表
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角
@@ -63,6 +65,9 @@ npm run typecheck
# 运行测试(Vitest 单元 + 组件测试)
npm test
# 运行 E2E 验收(先构建再对产物启动真实 Electron;发布前必跑)
npm run test:e2e
# 生产构建
npm run build
```
@@ -83,13 +88,15 @@ npm run build:linux # Linux AppImage / deb
```
src/
├── main/ # 主进程:窗口、菜单、文件读取与编码探测、IPC
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算)
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
├── components/ # 界面组件(DiffView / Toolbar 等)
├── components/ # 界面组件(DiffView / FolderView / Toolbar 等)
└── styles/ # 全局主题样式
e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主链路)
```
**安全设计**:渲染进程通过 `contextBridge` 隔离,不直接接触 Node 能力;文件读取等系统操作统一收敛在主进程,仅经 IPC 白名单暴露。
+10 -6
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.5.5`**
当前基线版本:**`0.6.0`**
---
@@ -75,8 +75,11 @@ z = 补丁版本号(Patch
0.5.2 ← 稳健性收尾与偏好记忆(已发布):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
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.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.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 · 文件夹对比(当前):
- 主进程解码 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
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
...
0.y.z ← 长期停留,永不进入 1.x
```
@@ -152,9 +155,10 @@ npm run build:win # Windows NSIS 安装包(在 Windows 上)
发布前 checklist
1. 更新 `package.json``version` 到新的 `0.y.z`
2. 更新本文件第 3 节演进记录
3. 通过质量门禁(typecheck + build
4. 打标签并推送到远程 `git.metona.cn/MetonaTeam/DiffLens`
5. 如需发布安装包,生成对应平台产物与 Release 说明
3. 通过质量门禁(typecheck + test + build
4. 通过 E2E 验收(`npm run test:e2e`,对 build 产物跑真实 Electron 主链路,0.6.0 起纳入发布必跑)
5. 打标签并推送到远程 `git.metona.cn/MetonaTeam/DiffLens`
6. 如需发布安装包,生成对应平台产物与 Release 说明
---
+50 -12
View File
@@ -18,11 +18,11 @@
| 层级 | 覆盖对象 | 工具 |
| --- | --- | --- |
| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器 | Vitest |
| 组件测试 | React 组件:应用空态、工具栏交互 | Vitest + Testing Library |
| 集成(演进中) | Electron IPC / 多进程协作 | 预留 Playwright 或 Electron 测试 |
| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器、解码探测、文件夹扫描 | Vitest |
| 组件测试 | React 组件:应用空态、工具栏交互、文件夹视图 | Vitest + Testing Library |
| E2E0.6.0 起) | 构建产物真实 Electron:主进程解码/写盘/文件夹扫描/偏好重启 | Playwright |
> 现阶段以**单元 + 组件**为主,覆盖核心、最易回归的部分
> 单元 + 组件覆盖核心逻辑与交互;E2E 覆盖 jsdom 无法触达的主进程真实链路(mock `window.api` 测不到的部分),发布前必跑
---
@@ -34,7 +34,7 @@
| 环境 | jsdom |
| React 测试 | @testing-library/react |
| 断言扩展 | @testing-library/jest-dom |
| 未来 E2E | Playwright预留,暂不引入 |
| E2E | Playwright`_electron` 模式,复用项目自带 Electron,无需下载浏览器 |
---
@@ -42,8 +42,17 @@
```
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
e2e/
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/偏好重启)
src/main/
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,随发布人工自测
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似)
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/
@@ -63,10 +72,11 @@ src/renderer/src/
├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── FolderView.test.tsx # 文件夹视图(统计/过滤/双击/虚拟滚动/截断与空态提示)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击
└── main.test.ts # 入口挂载冒烟测试
```
@@ -120,6 +130,7 @@ src/renderer/src/
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 「返回文件对比」清空文件夹状态回单文件空态
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -134,15 +145,42 @@ src/renderer/src/
### 5.7 应用状态持久化(`src/main/windowState.ts`
- parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略
- clampBounds:工作区内合法 bounds 原样保留;小于最小尺寸钳到最小值;超出工作区封顶;移出屏幕四向拉回(至少 60px 进入工作区);多显示器偏移工作区同样钳制;非整数取整;工作区小于最小尺寸时尺寸仍保持最小值
- 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,随发布人工自测
- 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,真实链路由 E2E 覆盖
### 5.8 解码与编码探测(`src/main/decode.ts`
- looksBinary:空输入 / NUL 立即判定 / 纯可见 ASCII / 控制字符占比超 5% / 常见空白不计入
- decodeTextUTF-8 BOM(剥离 BOM + 标注)/ UTF-16 LE 与 BE BOM / 仅 BOM 无内容 / 短于 BOM 长度走严格 UTF-8 路径 / 无 BOM 合法 UTF-8 与纯 ASCII / GBK 中文回退解码正确 / 空输入 / 含 NUL 非法 UTF-8 的 GBK 回退 + 二进制判定 / 替换符占比超 5% 判二进制
- worker 调度(阈值分流/崩溃回退)在主进程入口(覆盖率排除,大文件 worker 链路由 E2E 覆盖)
### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录)
- 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null)
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
### 5.10 文件夹视图(`FolderView.tsx`
- 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数)
- 过滤:仅看差异隐藏 same、关闭展示全部;双击回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;截断提示;空态提示(无文件 / 全部一致)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按全部条目撑满
### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron
- 启动冒烟:标题与双空态面板、空态无导出入口
- 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路)
- 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言)
- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致
- 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换
- 报告导出:stub 保存对话框 → 文件真实落盘且含结论与统计
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
---
## 6. 运行命令
```bash
npm test # 运行全部测试(CI / 提交前)
npm test # 运行全部单元 + 组件测试(CI / 提交前)
npm run test:watch # 监听模式,开发中用
npm run test:e2e # E2E 验收(先 build 再对产物启动真实 Electron;发布前必跑)
```
---
@@ -174,9 +212,9 @@ npm run build
## 9. 演进方向(后续迭代)
- 接入 Playwright 对打包后的 Electron 应用做端到端验收
- 对 IPC 层(open / decode / save-report)补充集成测试
- 大文件性能基准测试(虚拟滚动落地后
- 文件夹对比增强(0.7.0 候选):子目录树形展示 / 按扩展名过滤 / 文件夹对比报告导出 / 语义判定(套用忽略空白等选项)
- E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准
- 主进程 IPC 层补充集成测试(folder:scan 并发场景
---
+250
View File
@@ -0,0 +1,250 @@
import { test, expect } from '@playwright/test'
import { readFile } from 'fs/promises'
import { join } from 'path'
import {
launchApp,
stubOpenDialogSeq,
stubSaveDialog,
makeTmpDir,
removeTmpDir,
writeTextFixture,
pasteText
} from './helpers'
/**
* DiffLens E2E 主链路验收(对 build 产物运行真实 Electron):
* 覆盖 jsdom 单测无法触达的主进程解码 / 报告写盘 / 剪贴板 IPC / 偏好持久化生命周期。
* 运行前必须先 npm run build。
*/
test.describe('启动与基础交互', () => {
test('应用启动:窗口标题与双空态面板', async () => {
const { app, window } = await launchApp()
await expect(window).toHaveTitle('DiffLens')
await expect(window.getByText('选择左侧文件')).toBeVisible()
await expect(window.getByText('选择右侧文件')).toBeVisible()
// 空态不出现导出入口
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
await app.close()
})
test('粘贴两侧文本:差异渲染与统计徽章', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nbeta\ngamma')
await expect(window.getByRole('button', { name: '导出报告' })).toBeVisible()
await pasteText(window, 'right', 'alpha\nBETA\ngamma')
// 差异徽章与状态栏结论
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByText('~1')).toBeVisible()
// 修改行内容渲染(词级高亮行;exact 避免大小写不敏感的重复命中)
await expect(window.getByText('beta', { exact: true })).toBeVisible()
await expect(window.getByText('BETA', { exact: true })).toBeVisible()
await app.close()
})
})
test.describe('文件打开与编码识别(主进程真实解码链路)', () => {
let dir: string
test.beforeAll(async () => {
dir = await makeTmpDir('difflens-e2e-enc-')
})
test.afterAll(async () => {
await removeTmpDir(dir)
})
test('打开 GBK 与 UTF-8 BOM 文件:编码标注与内容正确渲染', async () => {
const gbkPath = await writeTextFixture(
dir,
'gbk.txt',
'中文内容第一行\n第二行数据',
'gbk'
)
const bomPath = await writeTextFixture(dir, 'bom.txt', 'BOM 首行\nBOM 第二行', 'utf8-bom')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[gbkPath], [bomPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
await expect(window.getByText('中文内容第一行')).toBeVisible()
await window.getByRole('button', { name: '打开右侧' }).click()
await expect(window.getByText('BOM 首行')).toBeVisible()
// 面板头编码标注(主进程探测结果;exact 避免文件名 gbk.txt 的子串误匹配)
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
await expect(window.locator('.pane-meta').filter({ hasText: 'UTF-8 (BOM)' })).toBeVisible()
await app.close()
})
test('大体积 GBK 文件:后台 worker 解码后完整渲染', async () => {
// 约 300KB(≥ 解码 worker 快路径阈值 256KB):走 worker_threads 后台解码链路
const bigLines = Array.from({ length: 15000 }, (_, i) => `中文大文件行${i}数据`).join('\n')
const bigPath = await writeTextFixture(dir, 'big-gbk.txt', bigLines, 'gbk')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[bigPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
// 首行在视口内;末行在虚拟滚动视口外,以状态栏总行数断言完整性
await expect(window.getByText('中文大文件行0数据')).toBeVisible()
await expect(window.locator('.status-item').filter({ hasText: '15000 行' })).toBeVisible()
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
await app.close()
})
})
test.describe('比较选项端到端', () => {
test('忽略大小写与忽略所有空白组合生效', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nbeta')
await pasteText(window, 'right', 'ALPHA\n beta')
// 默认严格对比:两处差异
await expect(window.getByText('有差异')).toBeVisible()
// 仅忽略大小写:仍有行首空白差异
await window.getByLabel('忽略大小写').check()
await expect(window.getByText('有差异')).toBeVisible()
// 再忽略所有空白:判为完全一致
await window.getByLabel('忽略所有空白').check()
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
await app.close()
})
test('字符级对比:跨行重组判为一致', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'ab cd')
await pasteText(window, 'right', 'ab\ncd')
await expect(window.getByText('有差异')).toBeVisible()
await window.getByLabel('字符级对比').check()
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
await app.close()
})
})
test.describe('差异导航与视图', () => {
test('F7 / Shift+F7 导航与仅看差异折叠', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'a\nX1\nb\nY1\nc')
await pasteText(window, 'right', 'a\nX2\nb\nY2\nc')
await expect(window.getByText('1 / 2')).toBeVisible()
await window.keyboard.press('F7')
await expect(window.getByText('2 / 2')).toBeVisible()
await window.keyboard.press('Shift+F7')
await expect(window.getByText('1 / 2')).toBeVisible()
// 仅看差异:未更改行折叠为提示行
await window.getByLabel('仅看差异').check()
await expect(window.getByText(/已折叠/).first()).toBeVisible()
await app.close()
})
test('交换左右侧后面板文件互换', async () => {
const dir = await makeTmpDir('difflens-e2e-swap-')
try {
const aPath = await writeTextFixture(dir, 'a.txt', 'aaa')
const bPath = await writeTextFixture(dir, 'b.txt', 'bbb')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[aPath], [bPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
await expect(window.locator('.pane-file').filter({ hasText: 'a.txt' })).toBeVisible()
await window.getByRole('button', { name: '打开右侧' }).click()
await expect(window.locator('.pane-file').filter({ hasText: 'b.txt' })).toBeVisible()
await window.getByRole('button', { name: '⇄ 交换左右' }).click()
// 面板头文件名互换(用 .pane-file 定位,避免与状态栏文件名混淆)
const paneFiles = window.locator('.pane-file')
await expect(paneFiles.nth(0)).toHaveText(/b\.txt/)
await expect(paneFiles.nth(1)).toHaveText(/a\.txt/)
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
test.describe('报告导出(主进程写盘链路)', () => {
test('导出 HTML 报告:文件落盘且包含结论', async () => {
const dir = await makeTmpDir('difflens-e2e-report-')
try {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'same\ndiff-left')
await pasteText(window, 'right', 'same\ndiff-right')
const savePath = join(dir, 'report.html')
await stubSaveDialog(app, savePath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: 'HTML 报告' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const html = await readFile(savePath, 'utf-8')
expect(html).toContain('<!doctype html>')
expect(html).toContain('共 1 处不同:修改 1 行')
// 统计卡与未更改行渲染(diff-left 被词级高亮拆分 span,不作整串断言)
expect(html).toContain('~1')
expect(html).toContain('same')
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
test.describe('文件夹对比(主进程真实扫描链路)', () => {
test('选择两个文件夹:状态判定、过滤与双击进入单文件对比', async () => {
const root = await makeTmpDir('difflens-e2e-folder-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(join(leftDir, 'sub'), { recursive: true })
await mkdir(join(rightDir, 'sub'), { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left content\nsame tail')
await wf(join(rightDir, 'diff.txt'), 'right content\nsame tail')
await wf(join(leftDir, 'only-left.txt'), 'L')
await wf(join(leftDir, 'sub', 'nested.txt'), 'nested L')
await wf(join(rightDir, 'sub', 'nested.txt'), 'nested R')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 文件夹模式:路径卡片与统计徽章
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 2')).toBeVisible()
await expect(window.getByText('仅左 1')).toBeVisible()
// 默认仅看差异:same.txt 被过滤,差异条目可见
await expect(window.getByText('diff.txt')).toBeVisible()
await expect(window.getByText('sub/nested.txt')).toBeVisible()
await expect(window.getByText('same.txt')).toHaveCount(0)
// 双击差异条目进入单文件对比
await window.getByText('diff.txt').dblclick()
await expect(window.getByText('left content')).toBeVisible()
await expect(window.getByText('right content')).toBeVisible()
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByRole('button', { name: '← 返回文件夹对比' })).toBeVisible()
// 返回文件夹列表(扫描结果保留)
await window.getByRole('button', { name: '← 返回文件夹对比' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('diff.txt')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('偏好持久化(跨实例重启)', () => {
test('重启后恢复比较选项与视图开关', async () => {
// 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好
const first = await launchApp({ fresh: true })
await pasteText(first.window, 'left', 'alpha')
await first.window.getByLabel('忽略大小写').check()
await first.window.getByLabel('仅看差异').check()
await expect(first.window.getByLabel('忽略大小写')).toBeChecked()
// 等 localStorage 写回完成再关闭
await first.window.waitForTimeout(500)
await first.app.close()
// 同一 userData 下重启(不清理):偏好应恢复
const second = await launchApp({ fresh: false })
await pasteText(second.window, 'left', 'alpha')
await expect(second.window.getByLabel('忽略大小写')).toBeChecked()
await expect(second.window.getByLabel('仅看差异')).toBeChecked()
await second.app.close()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import iconv from 'iconv-lite'
/**
* E2E 辅助:启动构建产物(out/)对应的真实 Electron 应用,
* 以及文件对话框 stub 与临时 fixture 生成。
* Electron 模式复用项目自带 Electron 二进制,无需下载 Playwright 浏览器。
*/
export interface LaunchedApp {
app: ElectronApplication
window: Page
}
/**
* 启动应用并等待首个窗口就绪。
* fresh(默认 true):清除上一轮测试残留的偏好持久化(localStorage 跨实例共享 userData
* 并重载页面,保证以默认选项启动;偏好持久化用例的验证实例显式传 false。
*/
export async function launchApp(options: { fresh?: boolean } = {}): Promise<LaunchedApp> {
const { fresh = true } = options
const app = await electron.launch({ args: ['.'] })
const window = await app.firstWindow()
await window.waitForLoadState('domcontentloaded')
if (fresh) {
await window.evaluate(() => window.localStorage.clear())
await window.reload()
await window.waitForLoadState('domcontentloaded')
}
return { app, window }
}
/**
* 主进程 stubshowOpenDialog 按调用序返回指定路径(每次调用取该组第一个文件)。
* 闭包计数状态保留在主进程,覆盖「打开左侧 → 打开右侧」的连续调用。
*/
export async function stubOpenDialogSeq(app: ElectronApplication, seqPaths: string[][]): Promise<void> {
await app.evaluate(({ dialog }, seq: string[][]) => {
let i = 0
dialog.showOpenDialog = async () => {
const paths = seq[Math.min(i, seq.length - 1)] ?? []
i++
return { canceled: false, filePaths: paths }
}
}, seqPaths)
}
/** 主进程 stubshowSaveDialog 固定返回指定保存路径 */
export async function stubSaveDialog(app: ElectronApplication, savePath: string): Promise<void> {
await app.evaluate(({ dialog }, p: string) => {
dialog.showSaveDialog = async () => ({ canceled: false, filePath: p })
}, savePath)
}
/** 创建临时目录(存放测试 fixture / 导出产物) */
export async function makeTmpDir(prefix = 'difflens-e2e-'): Promise<string> {
return mkdtemp(join(tmpdir(), prefix))
}
/** 清理临时目录(递归、不存在时忽略) */
export async function removeTmpDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true })
}
/**
* 写入文本 fixtureencoding 为 'utf8'(可带 bom)或 'gbk'iconv-lite 编码)。
* 返回文件绝对路径。
*/
export async function writeTextFixture(
dir: string,
name: string,
content: string,
encoding: 'utf8' | 'utf8-bom' | 'gbk' = 'utf8'
): Promise<string> {
const path = join(dir, name)
if (encoding === 'gbk') {
await writeFile(path, iconv.encode(content, 'gbk'))
} else if (encoding === 'utf8-bom') {
await writeFile(path, '\ufeff' + content, 'utf8')
} else {
await writeFile(path, content, 'utf8')
}
return path
}
/** 通过「粘贴文本」入口向指定侧粘贴内容(覆盖粘贴弹窗全链路) */
export async function pasteText(window: Page, side: 'left' | 'right', text: string): Promise<void> {
await window.getByRole('button', { name: '粘贴文本' }).click()
await window.getByRole('button', { name: side === 'left' ? '粘贴到左侧' : '粘贴到右侧' }).click()
await window.getByPlaceholder(/粘贴或输入/).fill(text)
await window.getByRole('button', { name: '开始对比' }).click()
}
+3
View File
@@ -23,6 +23,9 @@ files:
asarUnpack:
- resources/**
# 解码 worker 脚本及其依赖 chunkworker_threads 无法加载 asar 内脚本
# Electron 的 asar 补丁不覆盖 worker 线程),主进程代码一并 unpack 保证依赖完整
- out/main/**
win:
executableName: DiffLens
+15 -2
View File
@@ -4,9 +4,22 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
// iconv-lite 打包进产物:解码 worker 随 asarUnpack 加载,unpacked 路径下无法
// 解析 asar 内的 node_modulesElectron 的 asar 补丁不覆盖 worker 线程)
plugins: [externalizeDepsPlugin({ exclude: ['iconv-lite'] })],
build: {
sourcemap: false
sourcemap: false,
rollupOptions: {
// 双入口:主进程 + 解码 workerworker_threads 无法加载 asar 内脚本,需单独产物)
input: {
index: resolve('src/main/index.ts'),
decodeWorker: resolve('src/main/decodeWorker.ts')
},
output: {
entryFileNames: '[name].js',
format: 'cjs'
}
}
}
},
preload: {
+66 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.2.5",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.2.5",
"version": "0.5.5",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@types/diff": "^7.0.0",
@@ -1720,6 +1721,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -6715,6 +6732,53 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.1.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.5.5",
"version": "0.6.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
@@ -14,6 +14,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:e2e": "npm run build && playwright test",
"test:e2e:headed": "playwright test --headed",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@@ -29,6 +31,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@types/diff": "^7.0.0",
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from '@playwright/test'
/**
* Playwright E2E 配置:对构建产物(out/)启动真实 Electron 应用做端到端验收。
* - Electron 模式复用项目自带 Electron,无需下载 Playwright 浏览器
* - 不参与 vitest 单测与覆盖率统计;测试前必须先 npm run build
*/
export default defineConfig({
testDir: 'e2e',
// Electron 启动 + 窗口就绪整体放宽,避免慢机误报
timeout: 60_000,
expect: {
timeout: 10_000
},
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
outputDir: 'test-results',
use: {
trace: 'off',
headless: false
}
})
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import iconv from 'iconv-lite'
import { decodeText, looksBinary } from './decode'
describe('looksBinary - 二进制启发式', () => {
it('空输入不判为二进制', () => {
expect(looksBinary(new Uint8Array(0))).toBe(false)
})
it('出现 NUL 字节立即判定二进制', () => {
const buf = new Uint8Array([0x61, 0x00, 0x62])
expect(looksBinary(buf)).toBe(true)
})
it('纯可见 ASCII 判为文本', () => {
const buf = new TextEncoder().encode('hello world text')
expect(looksBinary(buf)).toBe(false)
})
it('不可见控制字符占比超过 5% 判为二进制', () => {
// 20 字节中 2 字节为 0x0110%)> 5%;探测窗口默认 8KB,短输入全量统计
const buf = new Uint8Array([0x61, 0x61, 0x01, 0x01, ...Array(16).fill(0x61)])
expect(looksBinary(buf)).toBe(true)
})
it('制表符与换行等常见空白不计入可疑字符', () => {
const buf = new TextEncoder().encode('a\tb\nc\r\nd')
expect(looksBinary(buf)).toBe(false)
})
})
describe('decodeText - BOM 探测', () => {
it('UTF-8 BOM:剥离 BOM 解码并标注', () => {
const body = new TextEncoder().encode('中文内容')
const buf = new Uint8Array([0xef, 0xbb, 0xbf, ...body])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('中文内容')
expect(r.binary).toBe(false)
})
it('UTF-16 LE BOM:剥离 BOM 解码并标注', () => {
// UTF-16 LE'中' = 0x2D 0x4E 小端
const buf = new Uint8Array([0xff, 0xfe, 0x2d, 0x4e])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 LE')
expect(r.text).toBe('中')
})
it('UTF-16 BE BOMiconv 解码并标注', () => {
// '中' 的 UTF-16 BE = 0x4E 0x2D
const buf = new Uint8Array([0xfe, 0xff, 0x4e, 0x2d])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 BE')
expect(r.text).toBe('中')
})
it('仅 BOM 无内容:返回空文本', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb, 0xbf]))
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('')
})
it('短于 BOM 长度的输入走严格 UTF-8 路径', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb]))
// 0xEF 0xBB 不是合法 UTF-8 序列开头 → GBK 回退
expect(r.encoding).toBe('GBK')
})
})
describe('decodeText - 严格 UTF-8 与 GBK 回退', () => {
it('无 BOM 合法 UTF-8 中文:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('你好,世界'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('你好,世界')
expect(r.binary).toBe(false)
})
it('纯 ASCII:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('plain ascii'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('plain ascii')
})
it('GBK 编码中文:回退 GBK 解码正确', () => {
const r = decodeText(iconv.encode('中文GBK编码内容', 'gbk'))
expect(r.encoding).toBe('GBK')
expect(r.text).toBe('中文GBK编码内容')
expect(r.binary).toBe(false)
})
it('空输入:UTF-8 空文本', () => {
const r = decodeText(new Uint8Array(0))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('')
})
it('含 NUL 字节的非法 UTF-8:GBK 回退且判定二进制(NUL 启发式)', () => {
// 0xFF 破坏严格 UTF-8 触发 GBK 回退;0x00 命中 NUL 启发式判二进制
const buf = new Uint8Array([0x41, 0x00, 0xff, 0x43])
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
it('替换符占比超过 5% 判定二进制(乱码防护)', () => {
// 构造 GBK 解出大量替换符的字节:非法 GBK 序列密集
const buf = new Uint8Array(200).fill(0xff)
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
})
+66
View File
@@ -0,0 +1,66 @@
import iconv from 'iconv-lite'
/**
* 文本解码与编码探测的纯逻辑模块(无 electron 依赖,vitest 直接测试)。
* 入参类型为 Uint8Array:主进程同步路径传 Buffer(其子类),
* 解码 worker 路径经结构化克隆收到 Uint8Array,两侧共用同一实现。
*/
/** 解码结果:文本 + 探测到的编码标注 + 二进制启发式判定 */
export interface DecodeResult {
text: string
encoding: string
binary: boolean
}
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
export function looksBinary(buf: Uint8Array): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
// iconv-lite 的类型签名要求 Buffer,运行时仅依赖 Uint8Array 接口(length/下标/subarray),
// 结构化克隆产生的 Uint8Array 直接传入安全,此断言不产生拷贝
const asBuffer = (u: Uint8Array): Buffer => u as unknown as Buffer
/**
* 探测文本编码并解码:优先 BOMUTF-8 / UTF-16 BE / UTF-16 LE),
* 其次严格 UTF-8,失败则回退 GBK 解码。
*/
export function decodeText(buf: Uint8Array): DecodeResult {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: new TextDecoder().decode(buf.subarray(3)), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(asBuffer(buf.subarray(2)), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return {
text: new TextDecoder('utf-16le').decode(buf.subarray(2)),
encoding: 'UTF-16 LE',
binary: false
}
}
// 严格 UTF-8 探测
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
} catch {
const text = iconv.decode(asBuffer(buf), 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
}
}
+13
View File
@@ -0,0 +1,13 @@
import { parentPort } from 'worker_threads'
import { decodeText } from './decode'
/**
* 解码 worker 入口(worker_threads)。
* 协议与渲染端 diffWorker 同构:请求 { jobId, buffer } → 响应 { jobId, result }。
* 大文件解码(iconv-lite GBK 为纯 JS 实现)在此线程执行,不阻塞主进程事件循环。
* 注意:本文件随构建单独打包并 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程),
* iconv-lite 由构建配置打包进本文件(unpacked 路径下无法解析 asar 内 node_modules)。
*/
parentPort?.on('message', (msg: { jobId: number; buffer: Uint8Array }) => {
parentPort?.postMessage({ jobId: msg.jobId, result: decodeText(msg.buffer) })
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { scanFolders, type FolderEntry } from './folderScan'
/** 以临时目录为根构造两个待对比文件夹 */
let leftDir: string
let rightDir: string
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'difflens-scan-'))
leftDir = join(root, 'left')
rightDir = join(root, 'right')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
})
afterAll(async () => {
await rm(join(leftDir, '..'), { recursive: true, force: true })
})
async function put(dir: 'left' | 'right', rel: string, content: string | Buffer): Promise<void> {
const path = join(dir === 'left' ? leftDir : rightDir, rel)
const idx = rel.lastIndexOf('/')
if (idx >= 0) await mkdir(join(path, '..'), { recursive: true })
await writeFile(path, content)
}
const byRel = (entries: FolderEntry[], rel: string): FolderEntry | undefined =>
entries.find((e) => e.rel === rel)
describe('scanFolders - 状态判定', () => {
it('两侧内容一致判为 same,内容不同(同大小)判为 different,大小不同判为 different', async () => {
await put('left', 'same.txt', 'hello')
await put('right', 'same.txt', 'hello')
await put('left', 'content-diff.txt', 'abc')
await put('right', 'content-diff.txt', 'abd')
await put('left', 'size-diff.txt', 'short')
await put('right', 'size-diff.txt', 'much longer text')
const r = await scanFolders(leftDir, rightDir)
expect(r.truncated).toBe(false)
expect(byRel(r.entries, 'same.txt')).toMatchObject({ status: 'same', leftSize: 5, rightSize: 5 })
expect(byRel(r.entries, 'content-diff.txt')?.status).toBe('different')
expect(byRel(r.entries, 'size-diff.txt')).toMatchObject({ status: 'different' })
})
it('单侧缺失判为 only,大小字段另一侧为 null', async () => {
await put('left', 'only-left.txt', 'L')
await put('right', 'only-right.txt', 'R')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'only-left.txt')).toMatchObject({
status: 'left-only',
leftSize: 1,
rightSize: null
})
expect(byRel(r.entries, 'only-right.txt')).toMatchObject({
status: 'right-only',
leftSize: null,
rightSize: 1
})
})
it('递归子目录条目按相对路径对齐(统一 / 分隔)', async () => {
await put('left', 'src/core/a.ts', 'export const a = 1\n')
await put('right', 'src/core/a.ts', 'export const a = 2\n')
await put('right', 'src/util/b.ts', 'export const b = 1\n')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'src/core/a.ts')?.status).toBe('different')
expect(byRel(r.entries, 'src/util/b.ts')?.status).toBe('right-only')
})
it('二进制内容同样按字节判定(含 NUL 字节文件)', async () => {
await put('left', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x03]))
await put('right', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x04]))
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'bin.dat')?.status).toBe('different')
})
it('symlink 被跳过(防环且不参与判定)', async () => {
// 建一个指向已存在子目录的 symlink,不应出现在结果中也不应导致递归
await symlink(join(leftDir, 'src'), join(leftDir, 'src-link'), 'dir').catch(() => {})
const r = await scanFolders(leftDir, rightDir)
expect(r.entries.some((e) => e.rel.startsWith('src-link/'))).toBe(false)
})
it('条目按相对路径字典序排序', async () => {
const r = await scanFolders(leftDir, rightDir)
const rels = r.entries.map((e) => e.rel)
expect([...rels].sort()).toEqual(rels)
})
it('total 为两侧枚举文件数的较大值', async () => {
const r = await scanFolders(leftDir, rightDir)
const leftCount = r.entries.filter((e) => e.leftSize !== null).length
const rightCount = r.entries.filter((e) => e.rightSize !== null).length
expect(r.total).toBeGreaterThanOrEqual(Math.max(leftCount, rightCount))
})
})
describe('scanFolders - 边界与选项', () => {
it('两个空目录返回空结果', async () => {
const emptyL = await mkdtemp(join(tmpdir(), 'difflens-empty-l-'))
const emptyR = await mkdtemp(join(tmpdir(), 'difflens-empty-r-'))
try {
const r = await scanFolders(emptyL, emptyR)
expect(r.entries).toEqual([])
expect(r.total).toBe(0)
expect(r.truncated).toBe(false)
} finally {
await rm(emptyL, { recursive: true, force: true })
await rm(emptyR, { recursive: true, force: true })
}
})
it('目录不存在时抛出异常(由 IPC 层转为错误结果)', async () => {
await expect(scanFolders(join(leftDir, 'not-exist'), rightDir)).rejects.toThrow()
})
it('maxFiles 截断:条目不完整且 truncated 为 true', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-trunc-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-trunc-r-'))
try {
for (let i = 0; i < 5; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
const res = await scanFolders(l, r, { maxFiles: 2 })
expect(res.truncated).toBe(true)
expect(res.total).toBe(5)
expect(res.entries.length).toBeLessThanOrEqual(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('超过 maxContentBytes 的同大小文件:头部采样一致判 same 且带 approximate 标注', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-big-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-big-r-'))
try {
// 100KB 文件、maxContentBytes 压到 1KB:走采样路径;头部 8KB 一致 → same + approximate
const head = Buffer.alloc(8 * 1024, 0x41)
const tailL = Buffer.alloc(100 * 1024 - head.length, 0x01)
const tailR = Buffer.alloc(100 * 1024 - head.length, 0x02)
await writeFile(join(l, 'big.bin'), Buffer.concat([head, tailL]))
await writeFile(join(r, 'big.bin'), Buffer.concat([head, tailR]))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')).toMatchObject({ status: 'same', approximate: true })
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('采样头部不同直接判 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-head-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-head-r-'))
try {
await writeFile(join(l, 'big.bin'), Buffer.alloc(100 * 1024, 0x01))
await writeFile(join(r, 'big.bin'), Buffer.alloc(100 * 1024, 0x02))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
+131
View File
@@ -0,0 +1,131 @@
import { readdir, stat, readFile, open } from 'fs/promises'
import { join, relative, sep } from 'path'
/**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
*/
/** 单个文件条目的对比状态 */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
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
}
export const FOLDER_MAX_FILES = 10000
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
/** 近似判定的采样头部字节数 */
const SAMPLE_BYTES = 8 * 1024
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
async function listFiles(
root: string,
maxFiles: number
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
const dirents = await readdir(root, { recursive: true, withFileTypes: true })
const rels: string[] = []
let total = 0
let truncated = false
for (const d of dirents) {
if (!d.isFile()) continue
total++
if (rels.length >= maxFiles) {
truncated = true
continue
}
// parentPath 为 Node 20.12+ 的 Dirent 属性(旧名 path
rels.push(relative(root, join(d.parentPath, d.name)).split(sep).join('/'))
}
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel))))
const files = new Map<string, number>()
rels.forEach((rel, i) => files.set(rel, stats[i].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;大小不同 → different
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
* 目录不存在/不可读时抛出异常,由 IPC 调用方转为错误结果。
*/
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 left = await listFiles(leftDir, maxFiles)
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) {
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 })
} else if (rSize === undefined) {
entries.push({ rel, status: 'left-only', leftSize: lSize, rightSize: null })
} else if (lSize !== rSize) {
entries.push({ 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 }
)
} 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 }
)
}
}
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
}
+112 -53
View File
@@ -1,9 +1,11 @@
import { join, dirname } from 'path'
import { join, dirname, sep } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron'
import { Worker } from 'worker_threads'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders } from './folderScan'
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
@@ -88,48 +90,88 @@ function createWindow(): void {
/** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
function looksBinary(buf: Buffer): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
/** 解码快路径阈值(256KB):小缓冲同步解码(毫秒级),大缓冲派发 worker 后台解码 */
const DECODE_FAST_PATH_BYTES = 256 * 1024
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding, binary }。
* 解码 workerworker_threads)单例管理。
* 大文件解码(GBK 纯 JS 解码可达数百毫秒)移入后台线程,避免阻塞主进程事件循环;
* 崩溃时拒绝在途任务并销毁实例(下次请求重建),调用方回退主进程同步解码。
*/
function decodeText(buf: Buffer): { text: string; encoding: string; binary: boolean } {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false }
}
// 严格 UTF-8 探测
let decodeWorker: Worker | null = null
let decodeJobSeq = 0
const pendingDecode = new Map<
number,
{ resolve: (r: DecodeResult) => void; reject: (e: Error) => void }
>()
/** 解码 worker 脚本路径:打包后位于 asar.unpackedElectron 的 asar 补丁不覆盖 worker 线程) */
function decodeWorkerPath(): string {
const p = join(__dirname, 'decodeWorker.js')
return p.includes(`app.asar${sep}`) ? p.replace(`app.asar${sep}`, `app.asar.unpacked${sep}`) : p
}
function getDecodeWorker(): Worker {
if (decodeWorker) return decodeWorker
const w = new Worker(decodeWorkerPath())
w.on('message', (msg: { jobId: number; result: DecodeResult }) => {
pendingDecode.get(msg.jobId)?.resolve(msg.result)
pendingDecode.delete(msg.jobId)
})
w.on('error', (err: Error) => {
for (const { reject } of pendingDecode.values()) reject(err)
pendingDecode.clear()
const dead = decodeWorker
decodeWorker = null
dead?.terminate().catch(() => {})
})
// 意外退出兜底:拒绝全部在途任务(error 分支已处理时此处为 no-op),下次请求重建
w.on('exit', () => {
for (const { reject } of pendingDecode.values()) reject(new Error('decode worker exited'))
pendingDecode.clear()
if (decodeWorker === w) decodeWorker = null
})
decodeWorker = w
return w
}
/** 大缓冲派发 worker 解码;worker 创建/运行失败时回退主进程同步解码(行为不降级) */
async function decodeMaybeWorker(buf: Buffer): Promise<DecodeResult> {
if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf)
const jobId = ++decodeJobSeq
return new Promise<DecodeResult>((resolve, reject) => {
let w: Worker
try {
w = getDecodeWorker()
} catch (e) {
reject(e instanceof Error ? e : new Error('decode worker unavailable'))
return
}
pendingDecode.set(jobId, { resolve, reject })
w.postMessage({ jobId, buffer: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) })
}).catch(() => decodeText(buf))
}
/** 按路径读取并解码文本文件(file:open 与 file:read-by-path 共用;10MB 上限) */
async function readTextFile(
filePath: string
): Promise<
| { path: string; name: string; text: string; encoding: string; binary: boolean }
| { error: 'too-large' | 'read-failed'; name: string; size?: number }
> {
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
const st = await fs.promises.stat(filePath)
if (st.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: st.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
const text = iconv.decode(buf, 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
return { error: 'read-failed', name }
}
const { text, encoding, binary } = await decodeMaybeWorker(buf)
return { path: filePath, name, text, encoding, binary }
}
/** IPC: 打开文件对话框并读取内容 */
@@ -151,24 +193,12 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const stat = await fs.promises.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed', name }
}
const { text, encoding, binary } = decodeText(buf)
return { path: filePath, name, text, encoding, binary }
return readTextFile(filePath)
})
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑,大缓冲走 worker */
ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
return decodeMaybeWorker(Buffer.from(buffer))
})
/** IPC: 保存差异报告(系统保存对话框 + 写入内容) */
@@ -200,6 +230,35 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
return true
})
/** IPC: 选择文件夹(文件夹对比入口;记忆上次目录) */
ipcMain.handle('folder:pick', async () => {
const result = await dialog.showOpenDialog({
title: '选择要对比的文件夹',
defaultPath: appState.lastDir,
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
const dirPath = result.filePaths[0]
appState.lastDir = dirPath
saveState()
return dirPath
})
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定,异步 fs 不阻塞主进程) */
ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string) => {
try {
return await scanFolders(leftDir, rightDir)
} catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const }
}
})
/** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */
ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
return readTextFile(filePath)
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens')
+31 -1
View File
@@ -22,6 +22,30 @@ export interface FileOpenError {
/** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */
export type FileOpenResult = FileData | FileOpenError | null
/** 文件夹对比条目状态(与主进程 folderScan 对齐) */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
/** 文件夹对比单个条目 */
export interface FolderEntry {
rel: string
status: FolderEntryStatus
leftSize: number | null
rightSize: number | null
approximate?: boolean
}
/** 文件夹扫描结果 */
export interface FolderScanResult {
entries: FolderEntry[]
truncated: boolean
total: number
}
/** 文件夹扫描失败(目录不存在/不可读) */
export interface FolderScanError {
error: 'scan-failed'
}
/** 主进程暴露给渲染进程的安全 API */
const api = {
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
@@ -33,7 +57,13 @@ const api = {
showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> =>
ipcRenderer.invoke('clipboard:write', text)
ipcRenderer.invoke('clipboard:write', text),
pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('folder:pick'),
scanFolder: (leftDir: string, rightDir: string): Promise<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke('folder:scan', leftDir, rightDir),
readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath)
}
export type DiffLensApi = typeof api
+228 -70
View File
@@ -18,6 +18,8 @@ import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff'
import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine'
import FolderView from './components/FolderView'
import type { FolderEntry, FolderScanResult } from '../../preload/index'
/** 生成默认报告文件名所需的时间戳:YYYYMMDD-HHmmss */
function stamp(): string {
@@ -154,6 +156,15 @@ function EmptyPane({
export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null)
// 工作模式:单文件对比 / 文件夹对比(双击文件夹条目临时切回 file,可返回)
const [mode, setMode] = useState<'file' | 'folder'>('file')
// 文件夹对比状态(扫描结果 + 两侧目录);从文件夹双击进入单文件对比时保留,供返回
const [folderState, setFolderState] = useState<
(FolderScanResult & { leftDir: string; rightDir: string }) | null
>(null)
const [folderScanning, setFolderScanning] = useState(false)
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件)
const [folderOnlyDiff, setFolderOnlyDiff] = useState(true)
// 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
const [initialPrefs] = useState(() => loadPrefs())
// 比较选项与视图开关:初始值从持久化偏好恢复
@@ -229,23 +240,19 @@ export default function App(): ReactElement {
[showToast]
)
const openPane = useCallback(
async (side: 'left' | 'right') => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
data = await window.api.openFile(side)
} catch {
showToast('文件读取失败,请重试')
return
}
if (!data) return
/** 将主进程文件读取结果应用到指定侧面板(openPane 与文件夹双击进入共用:错误提示/预警/装载) */
const applyFileData = useCallback(
(
side: 'left' | 'right',
data: NonNullable<Awaited<ReturnType<typeof window.api.readByPath>>>
): boolean => {
if ('error' in data) {
showToast(
data.error === 'too-large'
? `文件超过 10MB,暂不支持对比:${data.name ?? ''}`
: `文件读取失败:${data.name ?? ''}(可能被占用或权限不足)`
)
return
return false
}
if (data.binary) showToast(`疑似二进制文件,内容可能乱码:${data.name}`)
warnHeavyLines(data.text)
@@ -256,10 +263,26 @@ export default function App(): ReactElement {
}
if (side === 'left') setPaneL(pane)
else setPaneR(pane)
return true
},
[showToast, warnHeavyLines]
)
const openPane = useCallback(
async (side: 'left' | 'right') => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
data = await window.api.openFile(side)
} catch {
showToast('文件读取失败,请重试')
return
}
if (!data) return
applyFileData(side, data)
},
[showToast, applyFileData]
)
const dropPane = useCallback(
async (side: 'left' | 'right', files: File[]) => {
const file = files[0]
@@ -300,6 +323,91 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines]
)
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式) */
const runFolderScan = useCallback(
async (leftDir: string, rightDir: string): Promise<void> => {
setFolderScanning(true)
try {
const res = await window.api.scanFolder(leftDir, rightDir)
if ('error' in res) {
showToast('文件夹扫描失败,请检查目录是否存在且可访问')
return
}
setFolderState({ ...res, leftDir, rightDir })
setMode('folder')
} catch {
showToast('文件夹扫描失败,请重试')
} finally {
setFolderScanning(false)
}
},
[showToast]
)
/** 入口:连续选择左右两个文件夹后开始对比 */
const startFolderCompare = useCallback(async (): Promise<void> => {
let leftDir: string | null = null
let rightDir: string | null = null
try {
leftDir = await window.api.pickFolder()
if (!leftDir) return
rightDir = await window.api.pickFolder()
} catch {
showToast('选择文件夹失败,请重试')
return
}
if (!rightDir) return
await runFolderScan(leftDir, rightDir)
}, [showToast, runFolderScan])
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回) */
const openFolderEntry = useCallback(
async (entry: FolderEntry): Promise<void> => {
if (!folderState) return
const loadSide = async (side: 'left' | 'right', path: string | null): Promise<boolean> => {
// 条目单侧不存在:清空该侧面板(避免残留上一个条目的内容)
if (path === null) {
if (side === 'left') setPaneL(null)
else setPaneR(null)
return true
}
let data: Awaited<ReturnType<typeof window.api.readByPath>>
try {
data = await window.api.readByPath(path)
} catch {
showToast('文件读取失败,请重试')
return false
}
if (!data) return false
return applyFileData(side, data)
}
const okL = await loadSide(
'left',
entry.status === 'right-only' ? null : `${folderState.leftDir}/${entry.rel}`
)
const okR = await loadSide(
'right',
entry.status === 'left-only' ? null : `${folderState.rightDir}/${entry.rel}`
)
// 至少一侧装载成功才进入对比视图(双侧失败保持文件夹列表)
if (okL || okR) setMode('file')
},
[folderState, applyFileData, showToast]
)
/** 从单文件对比返回文件夹列表(清空两侧面板,文件夹扫描结果保留) */
const backToFolder = useCallback((): void => {
setPaneL(null)
setPaneR(null)
setMode('folder')
}, [])
/** 退出文件夹对比(清空文件夹状态,回到单文件空态) */
const exitFolder = useCallback((): void => {
setFolderState(null)
setMode('file')
}, [])
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
@@ -519,45 +627,77 @@ export default function App(): ReactElement {
</div>
</div>
<div className="header-spacer" />
<button className="btn ghost" onClick={() => void openPane('left')}>
</button>
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
<div className="tool-group export-wrap" ref={pasteWrapRef}>
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
</button>
{pasteOpen && (
<div className="export-menu">
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('left')
}}
>
{mode === 'file' && (
<>
{folderState && (
<button className="btn ghost" onClick={backToFolder} title="返回文件夹列表(扫描结果保留)">
</button>
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('right')
}}
>
</button>
</div>
)}
</div>
<button className="btn ghost" onClick={handleClear}>
</button>
)}
<button className="btn ghost" onClick={() => void openPane('left')}>
</button>
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
</>
)}
{mode === 'file' && (
<div className="tool-group export-wrap" ref={pasteWrapRef}>
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
</button>
{pasteOpen && (
<div className="export-menu">
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('left')
}}
>
</button>
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('right')
}}
>
</button>
</div>
)}
</div>
)}
{mode === 'file' && (
<>
<button className="btn ghost" onClick={handleClear}>
</button>
<button className="btn ghost" onClick={() => void startFolderCompare()} title="选择两个文件夹,对比全部文件差异">
</button>
</>
)}
</header>
{anyPane ? (
{mode === 'folder' && folderState ? (
<FolderView
leftDir={folderState.leftDir}
rightDir={folderState.rightDir}
entries={folderState.entries}
truncated={folderState.truncated}
total={folderState.total}
scanning={folderScanning}
onlyDiff={folderOnlyDiff}
onOnlyDiffChange={setFolderOnlyDiff}
onReopen={() => void startFolderCompare()}
onExit={exitFolder}
onOpenEntry={(e) => void openFolderEntry(e)}
/>
) : anyPane ? (
<>
<DiffView
items={displayRows}
@@ -594,31 +734,49 @@ export default function App(): ReactElement {
)}
<footer className="status-bar">
<div className="status-left">
<span className="status-item">
<b>{paneL ? paneL.meta.name : '—'}</b>
{paneL ? ` · ${leftLines}` : ''}
</span>
<span className="status-item">
<b>{paneR ? paneR.meta.name : '—'}</b>
{paneR ? ` · ${rightLines}` : ''}
</span>
<span className="status-item">
<b>{summary.changedLines}</b>
</span>
</div>
<div className="status-spacer" />
<div className="status-right">
{computing ? (
<span className="eq-badge busy"></span>
) : summary.changedLines === 0 ? (
<span className="eq-badge same"></span>
) : (
<span className="eq-badge diff"></span>
)}
</div>
{mode === 'folder' ? (
<>
<div className="status-left">
<span className="status-item"> · </span>
</div>
<div className="status-spacer" />
</>
) : (
<>
<div className="status-left">
<span className="status-item">
<b>{paneL ? paneL.meta.name : '—'}</b>
{paneL ? ` · ${leftLines}` : ''}
</span>
<span className="status-item">
<b>{paneR ? paneR.meta.name : '—'}</b>
{paneR ? ` · ${rightLines}` : ''}
</span>
<span className="status-item">
<b>{summary.changedLines}</b>
</span>
</div>
<div className="status-spacer" />
<div className="status-right">
{computing ? (
<span className="eq-badge busy"></span>
) : summary.changedLines === 0 ? (
<span className="eq-badge same"></span>
) : (
<span className="eq-badge diff"></span>
)}
</div>
</>
)}
</footer>
{folderScanning && mode !== 'folder' && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
</div>
)}
{computing && (
<div className="computing-overlay">
<span className="computing-ring" />
+133 -1
View File
@@ -18,7 +18,10 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }),
saveReport: async () => ({ ok: false, path: null }),
showInFolder: async () => true,
setClipboard: clipboardSpy
setClipboard: clipboardSpy,
pickFolder: async () => null,
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
readByPath: async () => null
}
return { ...base, ...overrides } as typeof window.api
}
@@ -961,3 +964,132 @@ describe('App - 偏好持久化(比较选项 + 仅看差异)', () => {
expect(saved2.onlyDiff).toBe(true)
})
})
describe('App - 文件夹对比', () => {
const folderEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 4 },
{ rel: 'c.txt', status: 'left-only' as const, leftSize: 2, rightSize: null }
]
/** 进入文件夹对比模式的公共流程:依次选择左右目录并返回固定扫描结果 */
async function enterFolderMode() {
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 })
})
const utils = render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('C:/left-dir')
return utils
}
it('依次选择两侧文件夹后进入文件夹模式:路径卡片、统计与状态栏', async () => {
await enterFolderMode()
expect(screen.getAllByText('D:/right-dir').length).toBeGreaterThan(0)
expect(screen.getByText('相同 1')).toBeInTheDocument()
expect(screen.getByText('不同 1')).toBeInTheDocument()
expect(screen.getByText('仅左 1')).toBeInTheDocument()
// 默认仅看差异:same 条目被过滤
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
// 状态栏切换为文件夹模式
expect(screen.getByText('文件夹对比 · 双击条目进入单文件对比')).toBeInTheDocument()
})
it('第一次选择取消不进入文件夹模式', async () => {
window.api = mockApi({ pickFolder: async () => null })
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
it('第二次选择取消不进入文件夹模式', async () => {
const dirs = ['C:/left-dir', null]
let call = 0
window.api = mockApi({ pickFolder: async () => dirs[call++] })
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
})
it('扫描失败给出提示且不进入文件夹模式', async () => {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ error: 'scan-failed' as const })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/文件夹扫描失败,请检查目录/)).toBeInTheDocument()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
it('双击差异条目加载两侧文件进入单文件对比,可返回文件夹', async () => {
window.api = mockApi({
pickFolder: async () => null,
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 }),
readByPath: async (p: string) => {
if (p === 'C:/left-dir/b.txt') {
return { path: p, name: 'b.txt', text: 'same\naaa', encoding: 'UTF-8', binary: false }
}
if (p === 'D:/right-dir/b.txt') {
return { path: p, name: 'b.txt', text: 'same\nbbb', encoding: 'UTF-8', binary: false }
}
return null
}
})
// 先进入文件夹模式(手动设置流程),再双击
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api.pickFolder = async () => dirs[call++] ?? null
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.dblClick(screen.getByText('b.txt'))
// 进入单文件对比视图:两侧内容渲染 + 差异统计(aaa/bbb 整词替换,词级高亮不拆分)
expect(await screen.findByText('aaa')).toBeInTheDocument()
expect(screen.getByText('bbb')).toBeInTheDocument()
expect(screen.getByText('有差异')).toBeInTheDocument()
// header 出现返回文件夹按钮
expect(screen.getByText('← 返回文件夹对比')).toBeInTheDocument()
// 返回文件夹:清空两侧面板,回到文件夹列表
fireEvent.click(screen.getByText('← 返回文件夹对比'))
expect(await screen.findByText('相同 1')).toBeInTheDocument()
expect(screen.queryByText('aaa')).not.toBeInTheDocument()
})
it('双击仅左侧条目:加载左侧并清空右侧', async () => {
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 }),
readByPath: async (p: string) =>
p === 'C:/left-dir/c.txt'
? { path: p, name: 'c.txt', text: 'only-c', encoding: 'UTF-8', binary: false }
: null
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('c.txt')
fireEvent.dblClick(screen.getByText('c.txt'))
expect(await screen.findByText('only-c')).toBeInTheDocument()
// 右侧面板为空槽(内容为 only-c 的删除行)
const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)
expect(paneFiles[0]).toBe('c.txt')
expect(paneFiles[1]).toBe('(未选择)')
})
it('返回文件对比后清空文件夹状态回到单文件空态', async () => {
await enterFolderMode()
fireEvent.click(screen.getByText('返回文件对比'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
// header 不再出现返回文件夹按钮(文件夹状态已清空)
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import FolderView, { FOLDER_ROW_HEIGHT } from './FolderView'
import type { FolderEntry } from '../../../preload/index'
const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
{ rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 },
{ rel: 'only-left.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 }
]
function renderView(entries: FolderEntry[] = ENTRIES, onlyDiff = true): ReturnType<typeof render> {
return render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={entries}
truncated={false}
total={entries.length}
scanning={false}
onlyDiff={onlyDiff}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
}
describe('FolderView', () => {
it('渲染两侧目录路径与操作按钮', () => {
renderView()
expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0)
expect(screen.getAllByText('D:/right').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: '重新选择' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument()
})
it('渲染统计徽章(相同/不同/仅左/仅右/总数)', () => {
renderView()
expect(screen.getByText('相同 1')).toBeInTheDocument()
expect(screen.getByText('不同 1')).toBeInTheDocument()
expect(screen.getByText('仅左 1')).toBeInTheDocument()
expect(screen.getByText('仅右 1')).toBeInTheDocument()
expect(screen.getByText('共 4 项')).toBeInTheDocument()
})
it('仅看差异开启时隐藏 same 条目,关闭后展示全部', () => {
const { rerender } = renderView()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
rerender(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByText('a.txt')).toBeInTheDocument()
})
it('双击条目触发 onOpenEntry(携带原始条目)', () => {
const onOpenEntry = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={onOpenEntry}
/>
)
fireEvent.dblClick(screen.getByText('b.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[1])
})
it('文件大小格式化(B/KB 与缺失占位)', () => {
renderView([...ENTRIES, { rel: 'big.bin', status: 'same', leftSize: 2048, rightSize: 2048 }], false)
expect(screen.getAllByText('2.0 KB').length).toBe(2)
expect(screen.getAllByText('—').length).toBeGreaterThan(0)
})
it('近似判定条目渲染 ≈ 标注', () => {
renderView([{ rel: 'huge.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }], false)
expect(screen.getByText('≈')).toBeInTheDocument()
})
it('截断时展示文件过多提示', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES.slice(0, 2)}
truncated={true}
total={9999}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
})
it('全部一致且开启仅看差异时给出空态提示', () => {
renderView([{ rel: 'x.txt', status: 'same', leftSize: 1, rightSize: 1 }])
expect(screen.getByText('没有差异文件(两侧内容全部一致)')).toBeInTheDocument()
})
it('空文件夹给出空态提示', () => {
renderView([])
expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument()
})
it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满', () => {
const many: FolderEntry[] = Array.from({ length: 1000 }, (_, i) => ({
rel: `f${i}.txt`,
status: 'different' as const,
leftSize: i,
rightSize: i + 1
}))
const { container } = renderView(many)
const rows = container.querySelectorAll('.folder-row')
expect(rows.length).toBeGreaterThan(0)
expect(rows.length).toBeLessThan(100)
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement
expect(sizer.style.height).toBe(`${1000 * FOLDER_ROW_HEIGHT}px`)
})
})
+188
View File
@@ -0,0 +1,188 @@
import {
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement
} from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
export const FOLDER_ROW_HEIGHT = 26
/** 上下各多渲染的缓冲行数 */
const OVERSCAN = 8
/** 视口高度兜底值(jsdom 无布局时使用) */
const FALLBACK_VIEWPORT = 600
export interface FolderViewProps {
leftDir: string
rightDir: string
entries: FolderEntry[]
truncated: boolean
total: number
scanning: boolean
/** 仅看差异(过滤 same 条目);文件夹模式默认开启 */
onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void
/** 重新选择两侧文件夹 */
onReopen: () => void
/** 返回文件对比模式(清空文件夹状态) */
onExit: () => void
/** 双击条目进入单文件对比 */
onOpenEntry: (entry: FolderEntry) => void
}
const STATUS_META: Record<FolderEntryStatus, { icon: string; label: string; cls: string }> = {
same: { icon: '●', label: '相同', cls: 'st-same' },
different: { icon: '●', label: '不同', cls: 'st-diff' },
'left-only': { icon: '◀', label: '仅左侧', cls: 'st-left' },
'right-only': { icon: '▶', label: '仅右侧', cls: 'st-right' }
}
/** 人类可读的文件大小 */
function fmtSize(n: number | null): string {
if (n === null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
export default function FolderView({
leftDir,
rightDir,
entries,
truncated,
total,
scanning,
onlyDiff,
onOnlyDiffChange,
onReopen,
onExit,
onOpenEntry
}: FolderViewProps): ReactElement {
// 统计(条目集不变时不重算)
const stats = useMemo(() => {
let same = 0
let different = 0
let leftOnly = 0
let rightOnly = 0
for (const e of entries) {
if (e.status === 'same') same++
else if (e.status === 'different') different++
else if (e.status === 'left-only') leftOnly++
else rightOnly++
}
return { same, different, leftOnly, rightOnly }
}, [entries])
const displayEntries = useMemo(
() => (onlyDiff ? entries.filter((e) => e.status !== 'same') : entries),
[entries, onlyDiff]
)
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 })
const updateRange = (): void => {
const el = scrollRef.current
if (!el) return
const vh = el.clientHeight || FALLBACK_VIEWPORT
const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN)
const end = Math.min(
displayEntries.length,
Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN
)
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
}
useLayoutEffect(() => {
updateRange()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayEntries])
return (
<div className="folder-view">
{/* 两侧文件夹卡片 */}
<div className="folder-head">
<div className="folder-card left" title={leftDir}>
<span className="pane-dot left" />
<span className="folder-path">{leftDir}</span>
</div>
<div className="folder-card right" title={rightDir}>
<span className="pane-dot right" />
<span className="folder-path">{rightDir}</span>
</div>
<div className="folder-actions">
<button className="btn ghost" onClick={onReopen}>
</button>
<button className="btn ghost" onClick={onExit}>
</button>
</div>
</div>
{/* 工具行:统计 + 过滤 */}
<div className="folder-toolbar">
<div className="folder-stats">
<span className="fstat st-same"> {stats.same}</span>
<span className="fstat st-diff"> {stats.different}</span>
<span className="fstat st-left"> {stats.leftOnly}</span>
<span className="fstat st-right"> {stats.rightOnly}</span>
<span className="fstat muted"> {entries.length} </span>
</div>
<label className="switch" title="隐藏两侧内容一致的文件,专注差异">
<input
type="checkbox"
checked={onlyDiff}
onChange={(e) => onOnlyDiffChange(e.target.checked)}
/>
</label>
{truncated && (
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
{total} {entries.length}
</span>
)}
</div>
{/* 条目列表(虚拟滚动) */}
<div className="folder-scroll" ref={scrollRef} onScroll={updateRange}>
<div style={{ height: displayEntries.length * FOLDER_ROW_HEIGHT }}>
{displayEntries.slice(range.start, range.end).map((e) => {
const meta = STATUS_META[e.status]
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
title={`双击对比:${e.rel}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{e.rel}</span>
{e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span>
</div>
)
})}
</div>
{!scanning && displayEntries.length === 0 && (
<div className="folder-empty-note">
{entries.length === 0
? '两个文件夹内都没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div>
)}
</div>
{scanning && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
</div>
)}
</div>
)
}
+165
View File
@@ -791,6 +791,171 @@ button:disabled {
background: rgba(34, 211, 238, 0.12);
}
/* ============ 文件夹对比视图 ============ */
.folder-view {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.folder-head {
flex: 0 0 auto;
display: flex;
align-items: stretch;
gap: 10px;
padding: 10px 18px 6px;
}
.folder-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.folder-path {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.folder-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 6px 18px 8px;
}
.folder-stats {
display: flex;
gap: 10px;
}
.fstat {
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 3px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
white-space: nowrap;
}
.fstat.muted {
color: var(--muted);
font-weight: 500;
}
.fstat.warn {
color: var(--mod-fg);
background: var(--mod-bg);
border-color: rgba(245, 158, 11, 0.3);
font-weight: 500;
}
.folder-scroll {
flex: 1;
overflow: auto;
position: relative;
border-top: 1px solid var(--border);
}
.folder-row {
display: flex;
align-items: center;
gap: 10px;
height: 26px;
line-height: 26px;
font-family: var(--mono);
font-size: 12.5px;
padding: 0 18px;
cursor: default;
user-select: none;
}
.folder-row:hover {
background: var(--surface-hover);
}
.folder-st-icon {
flex: none;
width: 16px;
text-align: center;
font-size: 10px;
}
.folder-row.st-same .folder-st-icon {
color: var(--add-fg);
}
.folder-row.st-diff .folder-st-icon {
color: var(--del-fg);
}
.folder-row.st-diff {
background: var(--del-bg);
}
.folder-row.st-left .folder-st-icon {
color: var(--accent);
}
.folder-row.st-right .folder-st-icon {
color: var(--accent-2);
}
.folder-rel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-approx {
flex: none;
color: var(--mod-fg);
font-size: 13px;
cursor: help;
}
.folder-size {
flex: none;
width: 76px;
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.folder-empty-note {
padding: 40px 0;
text-align: center;
color: var(--muted);
font-size: 13px;
letter-spacing: 1px;
}
/* ============ 后台计算遮罩 ============ */
.computing-overlay {
position: absolute;
+4 -1
View File
@@ -30,7 +30,10 @@ export default defineConfig({
// worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖
'src/renderer/src/diff/diffWorker.ts',
// 主进程入口依赖 electron 运行时,无法在 jsdom 测试(窗口/IPC 逻辑随发布人工自测)
'src/main/index.ts'
'src/main/index.ts',
// 解码 worker 线程入口(真实链路由 E2E 编码用例覆盖),其调用的
// decodeText 已由 decode 测试覆盖
'src/main/decodeWorker.ts'
],
all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败