diff --git a/.gitignore b/.gitignore index c257081..4bb41f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ node_modules/ out/ dist/ +# E2E +test-results/ +playwright-report/ + # 系统与编辑器 .DS_Store Thumbs.db diff --git a/README.md b/README.md index db291c6..df80cd4 100644 --- a/README.md +++ b/README.md @@ -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 白名单暴露。 diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 79d41ca..a5cd304 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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:disabled,checkbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 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 + decodeWorker,iconv-lite 打包进产物) · out/main 整体 asarUnpack(Electron 的 asar 补丁不覆盖 worker 线程,unpacked 路径下无法解析 asar 内 node_modules) + - Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stub(showOpenDialog/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 说明 --- diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 0f99030..b86f2d1 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -18,11 +18,11 @@ | 层级 | 覆盖对象 | 工具 | | --- | --- | --- | -| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器 | Vitest | -| 组件测试 | React 组件:应用空态、工具栏交互 | Vitest + Testing Library | -| 集成(演进中) | Electron IPC / 多进程协作 | 预留 Playwright 或 Electron 测试 | +| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器、解码探测、文件夹扫描 | Vitest | +| 组件测试 | React 组件:应用空态、工具栏交互、文件夹视图 | Vitest + Testing Library | +| E2E(0.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% / 常见空白不计入 +- decodeText:UTF-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 并发场景) --- diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..2b835ba --- /dev/null +++ b/e2e/app.spec.ts @@ -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('') + 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() + }) +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..56e2a7c --- /dev/null +++ b/e2e/helpers.ts @@ -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 { + 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 } +} + +/** + * 主进程 stub:showOpenDialog 按调用序返回指定路径(每次调用取该组第一个文件)。 + * 闭包计数状态保留在主进程,覆盖「打开左侧 → 打开右侧」的连续调用。 + */ +export async function stubOpenDialogSeq(app: ElectronApplication, seqPaths: string[][]): Promise { + 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) +} + +/** 主进程 stub:showSaveDialog 固定返回指定保存路径 */ +export async function stubSaveDialog(app: ElectronApplication, savePath: string): Promise { + await app.evaluate(({ dialog }, p: string) => { + dialog.showSaveDialog = async () => ({ canceled: false, filePath: p }) + }, savePath) +} + +/** 创建临时目录(存放测试 fixture / 导出产物) */ +export async function makeTmpDir(prefix = 'difflens-e2e-'): Promise { + return mkdtemp(join(tmpdir(), prefix)) +} + +/** 清理临时目录(递归、不存在时忽略) */ +export async function removeTmpDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }) +} + +/** + * 写入文本 fixture:encoding 为 'utf8'(可带 bom)或 'gbk'(iconv-lite 编码)。 + * 返回文件绝对路径。 + */ +export async function writeTextFixture( + dir: string, + name: string, + content: string, + encoding: 'utf8' | 'utf8-bom' | 'gbk' = 'utf8' +): Promise { + 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 { + 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() +} diff --git a/electron-builder.yml b/electron-builder.yml index 63bb0e3..96614eb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -23,6 +23,9 @@ files: asarUnpack: - resources/** + # 解码 worker 脚本及其依赖 chunk:worker_threads 无法加载 asar 内脚本 + # (Electron 的 asar 补丁不覆盖 worker 线程),主进程代码一并 unpack 保证依赖完整 + - out/main/** win: executableName: DiffLens diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1a10e37..32cccfe 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,9 +4,22 @@ import react from '@vitejs/plugin-react' export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + // iconv-lite 打包进产物:解码 worker 随 asarUnpack 加载,unpacked 路径下无法 + // 解析 asar 内的 node_modules(Electron 的 asar 补丁不覆盖 worker 线程) + plugins: [externalizeDepsPlugin({ exclude: ['iconv-lite'] })], build: { - sourcemap: false + sourcemap: false, + rollupOptions: { + // 双入口:主进程 + 解码 worker(worker_threads 无法加载 asar 内脚本,需单独产物) + input: { + index: resolve('src/main/index.ts'), + decodeWorker: resolve('src/main/decodeWorker.ts') + }, + output: { + entryFileNames: '[name].js', + format: 'cjs' + } + } } }, preload: { diff --git a/package-lock.json b/package-lock.json index 516ddb3..4cc4bad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1fbf2dc..6de52a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f4c8675 --- /dev/null +++ b/playwright.config.ts @@ -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 + } +}) diff --git a/src/main/decode.test.ts b/src/main/decode.test.ts new file mode 100644 index 0000000..f05720f --- /dev/null +++ b/src/main/decode.test.ts @@ -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 字节为 0x01(10%)> 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 BOM:iconv 解码并标注', () => { + // '中' 的 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) + }) +}) diff --git a/src/main/decode.ts b/src/main/decode.ts new file mode 100644 index 0000000..e72b72d --- /dev/null +++ b/src/main/decode.ts @@ -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 + +/** + * 探测文本编码并解码:优先 BOM(UTF-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 } + } +} diff --git a/src/main/decodeWorker.ts b/src/main/decodeWorker.ts new file mode 100644 index 0000000..7630bcf --- /dev/null +++ b/src/main/decodeWorker.ts @@ -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 实现)在此线程执行,不阻塞主进程事件循环。 + * 注意:本文件随构建单独打包并 asarUnpack(Electron 的 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) }) +}) diff --git a/src/main/folderScan.test.ts b/src/main/folderScan.test.ts new file mode 100644 index 0000000..da29a15 --- /dev/null +++ b/src/main/folderScan.test.ts @@ -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 { + 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 }) + } + }) +}) diff --git a/src/main/folderScan.ts b/src/main/folderScan.ts new file mode 100644 index 0000000..82430dd --- /dev/null +++ b/src/main/folderScan.ts @@ -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; 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() + rels.forEach((rel, i) => files.set(rel, stats[i].size)) + return { files, truncated, total } +} + +/** 读取文件头部指定字节数(近似判定采样) */ +async function readHead(path: string, bytes: number): Promise { + 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 { + 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) } +} diff --git a/src/main/index.ts b/src/main/index.ts index d5e22ad..23fd2ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 }。 + * 解码 worker(worker_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.unpacked(Electron 的 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 { + if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf) + const jobId = ++decodeJobSeq + return new Promise((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') diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e1445..5be8394 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => @@ -33,7 +57,13 @@ const api = { showInFolder: (filePath: string): Promise => ipcRenderer.invoke('file:show-in-folder', filePath), setClipboard: (text: string): Promise => - ipcRenderer.invoke('clipboard:write', text) + ipcRenderer.invoke('clipboard:write', text), + pickFolder: (): Promise => + ipcRenderer.invoke('folder:pick'), + scanFolder: (leftDir: string, rightDir: string): Promise => + ipcRenderer.invoke('folder:scan', leftDir, rightDir), + readByPath: (filePath: string): Promise => + ipcRenderer.invoke('file:read-by-path', filePath) } export type DiffLensApi = typeof api diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b11eb08..5529512 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) const [paneR, setPaneR] = useState(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> - try { - data = await window.api.openFile(side) - } catch { - showToast('文件读取失败,请重试') - return - } - if (!data) return + /** 将主进程文件读取结果应用到指定侧面板(openPane 与文件夹双击进入共用:错误提示/预警/装载) */ + const applyFileData = useCallback( + ( + side: 'left' | 'right', + data: NonNullable>> + ): 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> + 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 => { + 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 => { + 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 => { + if (!folderState) return + const loadSide = async (side: 'left' | 'right', path: string | null): Promise => { + // 条目单侧不存在:清空该侧面板(避免残留上一个条目的内容) + if (path === null) { + if (side === 'left') setPaneL(null) + else setPaneR(null) + return true + } + let data: Awaited> + 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 {
- - -
- - {pasteOpen && ( -
- - -
- )} -
- + )} + + + + )} + {mode === 'file' && ( +
+ + {pasteOpen && ( +
+ + +
+ )} +
+ )} + {mode === 'file' && ( + <> + + + + )} - {anyPane ? ( + {mode === 'folder' && folderState ? ( + void startFolderCompare()} + onExit={exitFolder} + onOpenEntry={(e) => void openFolderEntry(e)} + /> + ) : anyPane ? ( <> -
- - 左:{paneL ? paneL.meta.name : '—'} - {paneL ? ` · ${leftLines} 行` : ''} - - - 右:{paneR ? paneR.meta.name : '—'} - {paneR ? ` · ${rightLines} 行` : ''} - - - 变更行:{summary.changedLines} - -
-
-
- {computing ? ( - 正在计算差异… - ) : summary.changedLines === 0 ? ( - 两文件内容完全一致 - ) : ( - 有差异 - )} -
+ {mode === 'folder' ? ( + <> +
+ 文件夹对比 · 双击条目进入单文件对比 +
+
+ + ) : ( + <> +
+ + 左:{paneL ? paneL.meta.name : '—'} + {paneL ? ` · ${leftLines} 行` : ''} + + + 右:{paneR ? paneR.meta.name : '—'} + {paneR ? ` · ${rightLines} 行` : ''} + + + 变更行:{summary.changedLines} + +
+
+
+ {computing ? ( + 正在计算差异… + ) : summary.changedLines === 0 ? ( + 两文件内容完全一致 + ) : ( + 有差异 + )} +
+ + )} + {folderScanning && mode !== 'folder' && ( +
+ + 正在扫描文件夹… +
+ )} + {computing && (
diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index e5961b0..8dec1cf 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -18,7 +18,10 @@ function mockApi(overrides?: Partial): 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 } @@ -960,4 +963,133 @@ describe('App - 偏好持久化(比较选项 + 仅看差异)', () => { const saved2 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}') 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() + 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() + 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() + 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() + 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() + 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() + 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() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx new file mode 100644 index 0000000..7fa404a --- /dev/null +++ b/src/renderer/src/components/FolderView.test.tsx @@ -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 { + return render( + + ) +} + +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( + + ) + expect(screen.getByText('a.txt')).toBeInTheDocument() + }) + + it('双击条目触发 onOpenEntry(携带原始条目)', () => { + const onOpenEntry = vi.fn() + render( + + ) + 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( + + ) + 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`) + }) +}) diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx new file mode 100644 index 0000000..63ec6a9 --- /dev/null +++ b/src/renderer/src/components/FolderView.tsx @@ -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 = { + 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(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 ( +
+ {/* 两侧文件夹卡片 */} +
+
+ + {leftDir} +
+
+ + {rightDir} +
+
+ + +
+
+ + {/* 工具行:统计 + 过滤 */} +
+
+ 相同 {stats.same} + 不同 {stats.different} + 仅左 {stats.leftOnly} + 仅右 {stats.rightOnly} + 共 {entries.length} 项 +
+ + {truncated && ( + + ⚠ 文件过多({total}),仅展示前 {entries.length} 项 + + )} +
+ + {/* 条目列表(虚拟滚动) */} +
+
+ {displayEntries.slice(range.start, range.end).map((e) => { + const meta = STATUS_META[e.status] + return ( +
onOpenEntry(e)} + > + {meta.icon} + {e.rel} + {e.approximate && } + {fmtSize(e.leftSize)} + {fmtSize(e.rightSize)} +
+ ) + })} +
+ {!scanning && displayEntries.length === 0 && ( +
+ {entries.length === 0 + ? '两个文件夹内都没有文件' + : '没有差异文件(两侧内容全部一致)'} +
+ )} +
+ + {scanning && ( +
+ + 正在扫描文件夹… +
+ )} +
+ ) +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 6507323..4c8e26a 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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; diff --git a/vitest.config.ts b/vitest.config.ts index fb1a348..3a7919a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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%,否则测试失败