diff --git a/README.md b/README.md index 692e2dc..e0300a7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.6.1 +- 版本:0.6.2 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index b7fb94d..2acddb9 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.6.1`** +当前基线版本:**`0.6.2`** --- @@ -80,7 +80,8 @@ z = 补丁版本号(Patch) - 主进程解码 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.6.1 ← 文件夹对比收尾打磨(当前):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步) +0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步) +0.6.2 ← 文件夹对比稳健性与性能修复(当前):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 0ab85fe..0b4595f 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -130,7 +130,7 @@ src/renderer/src/ - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 -- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) +- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 @@ -155,7 +155,7 @@ src/renderer/src/ ### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录) - 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null) - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 -- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different +- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different - 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关(失败整体抛出由 Promise.all 语义保证,与串行实现一致,不单测) ### 5.10 文件夹视图(`FolderView.tsx`) diff --git a/package.json b/package.json index 2944633..04e34dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.6.1", + "version": "0.6.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/folderScan.test.ts b/src/main/folderScan.test.ts index 909e1ac..4430d28 100644 --- a/src/main/folderScan.test.ts +++ b/src/main/folderScan.test.ts @@ -136,6 +136,32 @@ describe('scanFolders - 边界与选项', () => { } }) + it('截断后不再深入子目录(凑满即停,total 为已遍历下限)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-stop-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-stop-r-')) + try { + // 根目录 3 个文件 + 子目录 1 个文件,maxFiles=2:凑满后子目录不被遍历 + // (readdir 目录内条目顺序不保证,但 3 个根文件计入 total 与顺序无关) + for (let i = 0; i < 3; i++) { + await writeFile(join(l, `f${i}.txt`), String(i)) + await writeFile(join(r, `f${i}.txt`), String(i)) + } + await mkdir(join(l, 'sub'), { recursive: true }) + await mkdir(join(r, 'sub'), { recursive: true }) + await writeFile(join(l, 'sub', 'inner.txt'), 'x') + await writeFile(join(r, 'sub', 'inner.txt'), 'x') + const res = await scanFolders(l, r, { maxFiles: 2 }) + expect(res.truncated).toBe(true) + // 根目录 3 文件全部计入(当前目录统计完整),sub 未被深入 + expect(res.total).toBe(3) + expect(res.entries.some((e) => e.rel.startsWith('sub/'))).toBe(false) + 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-')) diff --git a/src/main/folderScan.ts b/src/main/folderScan.ts index 301010b..c0475ae 100644 --- a/src/main/folderScan.ts +++ b/src/main/folderScan.ts @@ -1,5 +1,5 @@ import { readdir, stat, readFile, open } from 'fs/promises' -import { join, relative, sep } from 'path' +import { join } from 'path' /** * 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。 @@ -27,7 +27,7 @@ export interface ScanResult { entries: FolderEntry[] /** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */ truncated: boolean - /** 枚举发现的文件总数(含被截断未比对的文件,始终为真实总数) */ + /** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */ total: number } @@ -50,24 +50,38 @@ const SAMPLE_BYTES = 8 * 1024 */ export const SCAN_CONCURRENCY = 16 -/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */ +/** + * 手写 BFS 遍历目录树收集普通文件(相对路径统一 / 分隔,跳过 symlink 防环), + * 凑满 maxFiles 即停止深入:readdir({recursive:true}) 只能枚举完整棵树、无法提前终止, + * 误选超大目录(如含 node_modules)时枚举本身耗时数秒。 + * 截断后 total 为已遍历部分的文件数下限(剩余子树不再统计)。 + */ 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 + const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }] + while (queue.length > 0) { + const { dir, prefix } = queue.shift()! + const dirents = await readdir(dir, { withFileTypes: true }) + for (const d of dirents) { + // Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环) + if (d.isDirectory()) { + queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` }) + } else if (d.isFile()) { + total++ + if (rels.length >= maxFiles) { + truncated = true + } else { + rels.push(prefix === '' ? d.name : `${prefix}/${d.name}`) + } + } } - // parentPath 为 Node 20.12+ 的 Dirent 属性(旧名 path) - rels.push(relative(root, join(d.parentPath, d.name)).split(sep).join('/')) + // 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性) + if (truncated) break } // 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束) const stats = await Promise.all(rels.map((rel) => stat(join(root, rel)))) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7dda7a9..f3f41d8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -369,11 +369,16 @@ export default function App(): ReactElement { const openFolderEntry = useCallback( async (entry: FolderEntry): Promise => { if (!folderState) return + // 清空指定侧面板:双击新条目的任一读取失败路径都必须清掉旧内容, + // 否则成功侧与旧内容组成“名不符实”的对比(面板头还是旧文件名) + const clearSide = (side: 'left' | 'right'): void => { + if (side === 'left') setPaneL(null) + else setPaneR(null) + } const loadSide = async (side: 'left' | 'right', path: string | null): Promise => { // 条目单侧不存在:清空该侧面板(避免残留上一个条目的内容) if (path === null) { - if (side === 'left') setPaneL(null) - else setPaneR(null) + clearSide(side) return true } let data: Awaited> @@ -381,10 +386,16 @@ export default function App(): ReactElement { data = await window.api.readByPath(path) } catch { showToast('文件读取失败,请重试') + clearSide(side) return false } - if (!data) return false - return applyFileData(side, data) + if (!data) { + clearSide(side) + return false + } + const ok = applyFileData(side, data) + if (!ok) clearSide(side) + return ok } const okL = await loadSide( 'left', diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 8ad1e7d..7834ded 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1127,4 +1127,57 @@ describe('App - 文件夹对比', () => { expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument() expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument() }) + + it('双击条目一侧读取失败:失败侧清空显示未选择,不残留旧对比内容', async () => { + // 场景:先在 file 模式加载左侧旧文件 → 进文件夹模式 → 双击条目, + // 右侧 readByPath 返回读取失败 → 右侧必须清空(不能残留左侧旧对比的右侧内容) + 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/b.txt' + ? { path: p, name: 'b.txt', text: 'fresh-left', encoding: 'UTF-8', binary: false } + : { error: 'read-failed' as const, name: 'b.txt' } + }) + render() + // 先加载两侧旧文件(默认 mock 两侧均为 a.txt / line1 line2,制造潜在残留源) + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + // 双侧加载完成(相同内容 → 完全一致徽章出现) + await screen.findByText('两文件内容完全一致') + // 进入文件夹模式(此路径下旧面板内容未清),双击 b.txt:左侧成功、右侧读取失败 + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('b.txt') + fireEvent.dblClick(screen.getByText('b.txt')) + // 左侧为新条目内容,右侧清空为未选择(不残留旧对比内容) + expect(await screen.findByText('fresh-left')).toBeInTheDocument() + const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent) + expect(paneFiles[0]).toBe('b.txt') + expect(paneFiles[1]).toBe('(未选择)') + expect(screen.queryByText('line2')).not.toBeInTheDocument() + }) + + it('双击条目一侧 IPC 异常:同样清空失败侧并给出提示', 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) => { + if (p === 'D:/right-dir/b.txt') throw new Error('ipc boom') + return { path: p, name: 'b.txt', text: 'left-ok', encoding: 'UTF-8', binary: false } + } + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('b.txt') + fireEvent.dblClick(screen.getByText('b.txt')) + expect(await screen.findByText(/文件读取失败,请重试/)).toBeInTheDocument() + const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent) + expect(paneFiles[0]).toBe('b.txt') + expect(paneFiles[1]).toBe('(未选择)') + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 63ec6a9..178ca87 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -142,7 +142,7 @@ export default function FolderView({ {truncated && ( - ⚠ 文件过多({total}),仅展示前 {entries.length} 项 + ⚠ 文件过多({total}+),仅展示前 {entries.length} 项 )}