fix: v0.3.1 稳健性与测试补强 - 超长单行词级diff防护、打开对话框补所有文件筛选、虚拟滚动minWidth测试覆盖、滚动同步去锁化重构

This commit is contained in:
2026-08-17 22:32:15 +08:00
parent d910f6c352
commit 3406141b3c
10 changed files with 88 additions and 18 deletions
+2 -2
View File
@@ -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
---
+3 -2
View File
@@ -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
+2
View File
@@ -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`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.3.0",
"version": "0.3.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+3 -1
View File
@@ -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
@@ -219,6 +219,41 @@ describe('DiffView - 虚拟滚动', () => {
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
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<HTMLElement>
// 左: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<HTMLElement>
expect(columns[0].style.minWidth).toBe('')
expect(columns[1].style.minWidth).toBe('')
})
})
describe('DiffView - 折叠提示行', () => {
+5 -10
View File
@@ -204,7 +204,6 @@ export default function DiffView({
}: DiffViewProps): ReactElement {
const leftRef = useRef<HTMLDivElement>(null)
const rightRef = useRef<HTMLDivElement>(null)
const syncing = useRef(false)
// 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度
const probeRef = useRef<HTMLSpanElement>(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 不可用)
+24
View File
@@ -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')
+12 -1
View File
@@ -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',
+1 -1
View File
@@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean {
return TEXT_EXTENSIONS.includes(ext)
}
/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护 */
/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身 */
export const HEAVY_LINES = 50000
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */