diff --git a/README.md b/README.md index f156deb..ea13b67 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 -- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警 +- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行),便于分享与归档 @@ -93,7 +93,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.3.0 +- 版本:0.3.1 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 52db50c..c206cbf 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.3.0`** +当前基线版本:**`0.3.1`** --- @@ -63,7 +63,8 @@ z = 补丁版本号(Patch) 0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警 0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 -0.3.0 ← 大文件性能与差异导航增强(当前):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 +0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 +0.3.1 ← 稳健性与测试补强(当前):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 408ecbd..7ca653b 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -71,6 +71,7 @@ src/renderer/src/ - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines` 生效与关闭 +- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 ### 5.2 报告生成(`report.ts`) @@ -87,6 +88,7 @@ src/renderer/src/ ### 5.4 对比视图(`DiffView.tsx`) - 渲染:行内容、行号、词级高亮段、行尾空白标注 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 +- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽);探针未测得时不设置 - 折叠提示行:默认文案与自定义文案(仅看差异视图) - 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo` diff --git a/package.json b/package.json index 94b8547..b5834e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.3.0", + "version": "0.3.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 5540c88..4c8eed4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -94,7 +94,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, properties: ['openFile'], filters: [ - { name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'swift', 'kt', 'rb', 'php', 'sh', 'bat', 'ps1', 'log', 'csv', 'sql', 'vue', 'graphql', 'gradle'] } + { name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'swift', 'kt', 'rb', 'php', 'sh', 'bat', 'ps1', 'log', 'csv', 'sql', 'vue', 'graphql', 'gradle'] }, + // 兜底入口:拖拽白名单比对话框筛选项更全(svelte/tsv/lock 等),补“所有文件”消除两种入口的不一致 + { name: '所有文件', extensions: ['*'] } ] }) if (result.canceled || result.filePaths.length === 0) return null diff --git a/src/renderer/src/components/DiffView.test.tsx b/src/renderer/src/components/DiffView.test.tsx index 386fe81..37d51cd 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -219,6 +219,41 @@ describe('DiffView - 虚拟滚动', () => { const columns = container.querySelectorAll('.diff-columns') as NodeListOf expect(columns[0].style.height).toBe(`${100 * ROW_HEIGHT}px`) }) + + it('探针测得字符宽后按最大列宽设置 minWidth', () => { + // 探针 10 字符 mock 宽度 70px → 每字符 7px;左 50 列 / 右 60 列(均超过 colsOf 的 40 列下限) + const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 70, + height: 21, + top: 0, + left: 0, + right: 70, + bottom: 21, + x: 0, + y: 0, + toJSON: () => ({}) + } as DOMRect) + const wideRow: DiffRow = { + id: 'w1', + rowKind: 'unchanged', + isChanged: false, + left: { lineNo: 1, text: 'a'.repeat(50), segs: null }, + right: { lineNo: 1, text: '中'.repeat(30), segs: null } + } + const { container } = renderDiff([wideRow]) + const columns = container.querySelectorAll('.diff-columns') as NodeListOf + // 左:50 半角列 × 7 + 86 固定开销;右:30 全角 = 60 列 × 7 + 86 + expect(columns[0].style.minWidth).toBe('436px') + expect(columns[1].style.minWidth).toBe('506px') + rectSpy.mockRestore() + }) + + it('探针未测得宽度时不设置 minWidth(退回 CSS max-content)', () => { + const { container } = renderDiff(baseRows) + const columns = container.querySelectorAll('.diff-columns') as NodeListOf + expect(columns[0].style.minWidth).toBe('') + expect(columns[1].style.minWidth).toBe('') + }) }) describe('DiffView - 折叠提示行', () => { diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index df27d14..c054877 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -204,7 +204,6 @@ export default function DiffView({ }: DiffViewProps): ReactElement { const leftRef = useRef(null) const rightRef = useRef(null) - const syncing = useRef(false) // 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度 const probeRef = useRef(null) @@ -232,20 +231,16 @@ export default function DiffView({ const leftMinW = charW > 0 ? leftCols * charW + CONTENT_WIDTH_PAD : undefined const rightMinW = charW > 0 ? rightCols * charW + CONTENT_WIDTH_PAD : undefined + // 双向滚动同步(差值判断,无锁):一侧滚动后仅在两侧位置差超过 1px 时回写对侧; + // 回写触发的对侧 scroll 事件因差值归零自然终止,不依赖 rAF 时序假设,快速滚动不丢帧 const makeScrollSync = (target: 'left' | 'right') => (): void => { - if (syncing.current) return - syncing.current = true const src = target === 'left' ? leftRef.current : rightRef.current const dst = target === 'left' ? rightRef.current : leftRef.current - if (src && dst) { - dst.scrollTop = src.scrollTop - dst.scrollLeft = src.scrollLeft - } - requestAnimationFrame(() => { - syncing.current = false - }) + if (!src || !dst) return + if (Math.abs(dst.scrollTop - src.scrollTop) > 1) dst.scrollTop = src.scrollTop + if (Math.abs(dst.scrollLeft - src.scrollLeft) > 1) dst.scrollLeft = src.scrollLeft } // 差异导航定位:目标行换算为受控 scrollTop(虚拟化下行可能未渲染,scrollIntoView 不可用) diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index 3193904..afaa474 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -148,6 +148,30 @@ describe('computeDiff - 忽略空行', () => { }) }) +describe('computeDiff - 超长行词级防护', () => { + it('任一侧超过阈值时跳过词级高亮,行仍为 modified', () => { + const { rows, summary } = computeDiff('a'.repeat(5001), 'b'.repeat(5001)) + expect(summary.modified).toBe(1) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.segs).toBeNull() + expect(m.right.segs).toBeNull() + }) + + it('恰好阈值长度仍产生词级高亮', () => { + const { rows } = computeDiff('a'.repeat(5000), 'b'.repeat(5000)) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.segs).not.toBeNull() + expect(m.right.segs).not.toBeNull() + }) + + it('一侧短一侧超长同样跳过', () => { + const { rows } = computeDiff('short', 'z'.repeat(6000)) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.segs).toBeNull() + expect(m.right.segs).toBeNull() + }) +}) + describe('computeDiff - 行切分细节', () => { it('结尾换行不产生多余空行', () => { const { rows, summary } = computeDiff('a\n', 'a') diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index fb29730..0710dae 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -58,6 +58,12 @@ interface NumberedLine { lineNo: number } +/** + * 词级高亮的单行长度上限:任一侧超长即跳过词级 diff,整行按行级背景展示。 + * 压缩产物 / base64 / 无换行日志的单行可达数 MB,词法 diff 会长时间阻塞渲染进程。 + */ +const WORD_DIFF_MAX_LEN = 5000 + /** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */ function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } { const parts = diffWordsWithSpace(leftText, rightText) @@ -146,7 +152,12 @@ export function computeDiff( const l = queuedRemoved[i] const r = addedRows[i] if (l && r) { - const { left: lSegs, right: rSegs } = wordSegments(l.text, r.text) + // 任一侧超长则跳过词级高亮(segs 置空),行仍按 modified 展示行级背景 + const skipWordDiff = + l.text.length > WORD_DIFF_MAX_LEN || r.text.length > WORD_DIFF_MAX_LEN + const { left: lSegs, right: rSegs } = skipWordDiff + ? { left: null, right: null } + : wordSegments(l.text, r.text) rows.push({ id: `r${rowSeq++}`, rowKind: 'modified', diff --git a/src/renderer/src/diff/textUtils.ts b/src/renderer/src/diff/textUtils.ts index d3016f3..072fd6d 100644 --- a/src/renderer/src/diff/textUtils.ts +++ b/src/renderer/src/diff/textUtils.ts @@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean { return TEXT_EXTENSIONS.includes(ext) } -/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护) */ +/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) */ export const HEAVY_LINES = 50000 /** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */