From b46826334a36b428efbea5c1ddec9cf0594fa3e7 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 17 Aug 2026 21:48:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.2.6=20=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=94=B6=E5=B0=BE=E4=B8=8E=E8=BE=B9=E8=A7=92?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=20-=20=E4=BF=9D=E5=AD=98=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=86=99=E7=9B=98=E3=80=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=A7=A3=E7=A0=81handler=E5=BC=82=E6=AD=A5=E5=8C=96=E3=80=81?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=E7=B2=98=E8=B4=B4=E6=8B=A6=E6=88=AA=E3=80=81?= =?UTF-8?q?=E8=B6=85=E5=A4=9A=E8=A1=8C=E5=8D=A1=E9=A1=BF=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E3=80=81=E6=B8=85=E7=90=86mac=E6=97=A0=E6=95=88=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=85=8D=E7=BD=AE=E3=80=81=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +-- docs/应用开发与版本迭代规范.md | 8 +++--- docs/测试策略与方案.md | 22 +++++++++++------ electron-builder.yml | 1 - package.json | 2 +- src/main/index.ts | 5 ++-- src/renderer/src/App.tsx | 25 ++++++++++++++++--- src/renderer/src/__tests__/App.test.tsx | 13 ++++++++++ src/renderer/src/diff/diffEngine.test.ts | 31 ++++++++++++++++++++++++ src/renderer/src/diff/diffEngine.ts | 4 +-- src/renderer/src/diff/textUtils.test.ts | 24 +++++++++++++++++- src/renderer/src/diff/textUtils.ts | 10 ++++++++ 12 files changed, 126 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0429127..8c86fb3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 -- **大文件防护**:超过 10MB 的文件自动拦截并提示,避免界面卡死 +- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出滚动卡顿预警 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 @@ -90,7 +90,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.2.5 +- 版本:0.2.6 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 6d03bab..79b6596 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.2.5`** +当前基线版本:**`0.2.6`** --- @@ -61,8 +61,9 @@ z = 补丁版本号(Patch) 0.2.2 ← 体验打磨(已发布):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件 0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录 0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警 -0.2.5 ← 一致性与边角修复(当前):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 -0.3.0 ← 新增功能 +0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 +0.2.6 ← 主进程异步收尾与边角防护(当前):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 +0.3.0 ← 新增功能(候选:虚拟滚动 · 仅看差异过滤 · 差异导航快捷键 · 交换左右侧 · 忽略空行 · 主进程解码 worker 化) ... 0.y.z ← 长期停留,永不进入 1.x ``` @@ -149,6 +150,7 @@ npm run build:win # Windows NSIS 安装包(在 Windows 上) | --- | --- | | `package.json` | `version`(唯一版本来源)、`name`、`productName` 保持 DiffLens | | `electron-builder.yml` | `productName` / `executableName` / `artifactName` 保持 DiffLens | +| `README.md` | 特性描述与发布版本号随版本同步 | | 本文件 | 第 3 节演进记录同步补写 | > 版本号**只以 `package.json` 的 `version` 为唯一来源**,其余打包产物名由它派生。 diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 1dba45f..a57d31c 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -41,14 +41,22 @@ ## 4. 目录结构 ``` -vitest.config.ts # Vitest 配置(jsdom + 测试范围) +vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁) src/renderer/src/ -├── test/setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup +├── test/ +│ └── setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup ├── diff/ │ ├── diffEngine.test.ts # diff 引擎单元测试 -│ └── report.test.ts # 报告生成测试 +│ ├── report.test.ts # 报告生成测试 +│ └── textUtils.test.ts # 文本扩展名与行数统计测试 +├── components/ +│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) +│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) +│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单) └── __tests__/ - └── App.test.tsx # 应用冒烟测试 + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错) + └── main.test.ts # 入口挂载冒烟测试 ``` > 约定:测试文件与被测文件同目录(`*.test.ts(x)`),或集中放各模块下 `__tests__`。 @@ -105,10 +113,10 @@ npm run build | 模块 | 目标 | | --- | --- | -| 逻辑层(diff / report) | ≥ 80% 行覆盖(当前以核心用例保证关键路径) | -| 组件层(App) | 覆盖主流程空态与关键交互 | +| 逻辑层(diff / report / textUtils) | 行覆盖 ≥ 80% | +| 组件层(components / App) | 覆盖主流程空态与关键交互 | -> 具体门槛随各模块成熟度逐步收紧;当前先保证**关键路径断言完整**。 +> 80% 四项阈值(statements / branches / functions / lines)已写入 `vitest.config.ts` 的 `coverage.thresholds`,作为 CI 硬门禁强制执行,未达标即测试失败。 --- diff --git a/electron-builder.yml b/electron-builder.yml index 7cf9830..9e712eb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -44,7 +44,6 @@ mac: - target: dmg arch: - arm64 - entitlementsInherit: build/entitlements.mac.plist linux: icon: assets/logo.png diff --git a/package.json b/package.json index 13a0946..7c81585 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.2.5", + "version": "0.2.6", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 389e903..5540c88 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,7 +115,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { }) /** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */ -ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => { +ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => { return decodeText(Buffer.from(buffer)) }) @@ -131,7 +131,8 @@ ipcMain.handle('file:save-report', async (_event, content: string, defaultName: ] }) if (result.canceled || !result.filePath) return { ok: false, path: null } - fs.writeFileSync(result.filePath, content, 'utf-8') + // 异步写盘,避免大报告(MB 级 HTML)同步写入时阻塞主进程事件循环 + await fs.promises.writeFile(result.filePath, content, 'utf-8') return { ok: true, path: result.filePath } }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8f8389e..2cef9a0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,7 +14,7 @@ import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import TextInputModal from './components/TextInputModal' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' -import { isTextFile } from './diff/textUtils' +import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { useDismiss } from './hooks/useDismiss' import type { PaneMeta } from './components/DiffView' import type { SideCell } from './diff/diffEngine' @@ -119,6 +119,15 @@ export default function App(): ReactElement { } }, []) + // 超多行预警:行数超过阈值时提示滚动可能卡顿(不阻断加载) + const warnHeavyLines = useCallback( + (text: string): void => { + const lines = countLines(text) + if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),滚动可能出现卡顿`) + }, + [showToast] + ) + const openPane = useCallback( async (side: 'left' | 'right') => { let data: Awaited> @@ -138,6 +147,7 @@ export default function App(): ReactElement { return } if (data.binary) showToast(`疑似二进制文件,内容可能乱码:${data.name}`) + warnHeavyLines(data.text) const pane: PaneState = { meta: { name: data.name, encoding: data.encoding }, path: data.path, @@ -146,7 +156,7 @@ export default function App(): ReactElement { if (side === 'left') setPaneL(pane) else setPaneR(pane) }, - [showToast] + [showToast, warnHeavyLines] ) const dropPane = useCallback( @@ -181,11 +191,12 @@ export default function App(): ReactElement { text = fallback } if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`) + warnHeavyLines(text) const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text } if (side === 'left') setPaneL(pane) else setPaneR(pane) }, - [showToast] + [showToast, warnHeavyLines] ) const diff: DiffResult = useMemo(() => { @@ -276,17 +287,23 @@ export default function App(): ReactElement { const confirmPaste = useCallback( (side: 'left' | 'right', text: string) => { + // 空白内容拦截:进入对比只会得到空面板与"完全一致"的误导性结果 + if (text.trim() === '') { + showToast('内容为空,请先输入或粘贴文本') + return + } // 与文件入口的 10MB 字节上限保持对称:超限拒绝且不关闭弹窗,保留内容供编辑 if (new TextEncoder().encode(text).length > MAX_FILE_SIZE) { showToast('粘贴内容超过 10MB,暂不支持对比') return } + warnHeavyLines(text) const pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text } if (side === 'left') setPaneL(pane) else setPaneR(pane) setPasteSide(null) }, - [showToast] + [showToast, warnHeavyLines] ) const leftMeta = paneL?.meta ?? null diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index c56541c..e55410d 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -415,4 +415,17 @@ describe('App - 手动粘贴文本', () => { expect(screen.getByText('开始对比')).toBeInTheDocument() expect(screen.queryByText('导出报告')).not.toBeInTheDocument() }) + + it('粘贴空白内容被拒绝且弹窗保持打开', () => { + render() + fireEvent.click(screen.getByText('粘贴文本')) + fireEvent.click(screen.getByText('粘贴到左侧')) + fireEvent.change(screen.getByPlaceholderText(/粘贴或输入/), { + target: { value: ' \n ' } + }) + fireEvent.click(screen.getByText('开始对比')) + expect(screen.getByText(/内容为空/)).toBeInTheDocument() + expect(screen.getByText('开始对比')).toBeInTheDocument() + expect(screen.queryByText('导出报告')).not.toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index 4965c08..78db928 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -67,6 +67,32 @@ describe('computeDiff - 词级修改', () => { }) }) +describe('computeDiff - 多块修改与不配对增删', () => { + it('多个独立修改块各自配对为 modified', () => { + const { rows, summary } = computeDiff('a\nX\nb\nY\nc', 'a\nZ\nb\nW\nc') + expect(summary.modified).toBe(2) + const mods = rows.filter((r) => r.rowKind === 'modified') + expect(mods[0].left.text).toBe('X') + expect(mods[0].right.text).toBe('Z') + expect(mods[1].left.text).toBe('Y') + expect(mods[1].right.text).toBe('W') + }) + + it('删除多于新增时多余删除行降级为 removed', () => { + const { summary } = computeDiff('l1\nl2\nl3\nl4', 'l1\nl9\nl4') + // l2 与 l9 配对为修改,l3 无新增可配对 → removed + expect(summary.modified).toBe(1) + expect(summary.deleted).toBe(1) + }) + + it('新增多于删除时多余新增行保持 added', () => { + const { rows, summary } = computeDiff('a\nc', 'a\nb1\nb2\nc') + expect(summary.inserted).toBe(2) + const added = rows.filter((r) => r.rowKind === 'added') + expect(added.map((r) => r.right.text)).toEqual(['b1', 'b2']) + }) +}) + describe('computeDiff - 忽略选项', () => { it('忽略大小写', () => { const { summary } = computeDiff('Alpha', 'alpha', { ignoreCase: true }) @@ -78,6 +104,11 @@ describe('computeDiff - 忽略选项', () => { expect(summary.changedLines).toBe(0) }) + it('空白与大小写忽略选项组合生效', () => { + const { summary } = computeDiff(' Alpha ', 'alpha', { trimWhitespace: true, ignoreCase: true }) + expect(summary.changedLines).toBe(0) + }) + it('未开启忽略时同一纯净差异仍被识别', () => { const { summary } = computeDiff(' value ', 'value') expect(summary.changedLines).toBeGreaterThan(0) diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index 885dc7c..11a0f54 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -68,8 +68,8 @@ function wordSegments(leftText: string, rightText: string): { left: Seg[]; right return { left, right } } -/** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行 */ -function splitLines(text: string): string[] { +/** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行(导出供行数统计复用,保持切分规则一致) */ +export function splitLines(text: string): string[] { if (text === '') return [] const lines = text.split(/\r?\n/) if (lines[lines.length - 1] === '') lines.pop() diff --git a/src/renderer/src/diff/textUtils.test.ts b/src/renderer/src/diff/textUtils.test.ts index af28ce9..f455948 100644 --- a/src/renderer/src/diff/textUtils.test.ts +++ b/src/renderer/src/diff/textUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { TEXT_EXTENSIONS, isTextFile } from './textUtils' +import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines } from './textUtils' describe('textUtils', () => { it('常见文本扩展名放行', () => { @@ -30,4 +30,26 @@ describe('textUtils', () => { expect(TEXT_EXTENSIONS.length).toBeGreaterThan(0) expect(TEXT_EXTENSIONS).toContain('txt') }) +}) + +describe('countLines / HEAVY_LINES', () => { + it('空文本为 0 行', () => { + expect(countLines('')).toBe(0) + }) + + it('无结尾换行按实际行数', () => { + expect(countLines('a\nb\nc')).toBe(3) + }) + + it('结尾换行不产生额外空行(与 diff 引擎切分一致)', () => { + expect(countLines('a\nb\n')).toBe(2) + }) + + it('CRLF 换行同样切分', () => { + expect(countLines('a\r\nb')).toBe(2) + }) + + it('超多行预警阈值为 50000', () => { + expect(HEAVY_LINES).toBe(50000) + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/textUtils.ts b/src/renderer/src/diff/textUtils.ts index 244bbf5..11eb2f1 100644 --- a/src/renderer/src/diff/textUtils.ts +++ b/src/renderer/src/diff/textUtils.ts @@ -1,3 +1,5 @@ +import { splitLines } from './diffEngine' + /** 允许打开/拖拽的文本类扩展名(小写,不含点) */ export const TEXT_EXTENSIONS: string[] = [ 'txt', @@ -71,4 +73,12 @@ export function isTextFile(name: string): boolean { if (dot < 0) return true const ext = name.slice(dot + 1).toLowerCase() return TEXT_EXTENSIONS.includes(ext) +} + +/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护) */ +export const HEAVY_LINES = 50000 + +/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */ +export function countLines(text: string): number { + return splitLines(text).length } \ No newline at end of file