fix: v0.3.1 稳健性与测试补强 - 超长单行词级diff防护、打开对话框补所有文件筛选、虚拟滚动minWidth测试覆盖、滚动同步去锁化重构
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+3
-1
@@ -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 - 折叠提示行', () => {
|
||||
|
||||
@@ -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 不可用)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean {
|
||||
return TEXT_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护) */
|
||||
/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) */
|
||||
export const HEAVY_LINES = 50000
|
||||
|
||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||
|
||||
Reference in New Issue
Block a user