feat: 字符级对比分块锚定增强 - 行归一化锚定+变更组字符流两阶段算法使大文件不再整篇受限、双限阈值放宽至5万/5000并新增组总流上限10万、重输入阈值随动、开关与报告说明同步分块语义(0.6.3)
This commit is contained in:
@@ -20,7 +20,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
- **交换左右侧**:一键互换两侧内容与视角
|
||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
||||
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;内容超 3 万字符或差异过大时自动降级行级对比并提示(编辑距离封顶,最坏约 2 秒出结果,不会卡死)
|
||||
- **字符级对比**:空白与换行结构全部不参与判等,跨行重组(如排版重排、换行位置变化)也能判等;相同内容自动行锚定跳过计算、差异按变更组分块对比——大文件不再整篇受字符量限制;字符差异精确回映射到行内高亮;仅差异过大(单组超 5 万字符、编辑距离超限或变更总量过大)时自动降级行级对比并提示(编辑距离封顶,最坏耗时锁定在秒级,后台计算不卡界面)
|
||||
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);内容或差异过大自动降级时开关旁标注“已降级”,状态一目了然
|
||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
|
||||
@@ -108,7 +108,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.6.2
|
||||
- 版本:0.6.3
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||
|
||||
当前基线版本:**`0.6.2`**
|
||||
当前基线版本:**`0.6.3`**
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +81,8 @@ z = 补丁版本号(Patch)
|
||||
- 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.2 ← 文件夹对比稳健性与性能修复(当前):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
|
||||
0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
|
||||
0.6.3 ← 字符级对比分块锚定增强(当前):对比改两阶段——每行剔除全部空白判等找安全锚行(相同内容不进入 diffChars),连续增删行块合为“变更组”、组内归一化字符流 diffChars——归一化流总量不再受单次上限约束,大文件(总量远超上限)只要变更集中在组内限额之下即可字符级完成(输出仍为 DiffRow,视图/报告/导航零改动;无锚行时唯一组即全文,与旧整篇路径同构) · 双限阈值放宽(单组字符量 3 万→5 万、编辑距离 3000→5000)并新增变更组总流上限 10 万兜底累计耗时(超限整体降级语义不变) · 重输入 worker 阈值随动(原始字符 7500→5000,同步快路径最坏耗时口径不变) · 字符级开关提示与报告选项说明同步分块语义
|
||||
...
|
||||
0.y.z ← 长期停留,永不进入 1.x
|
||||
```
|
||||
|
||||
+2
-2
@@ -92,7 +92,7 @@ src/renderer/src/
|
||||
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
||||
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
|
||||
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);分块锚定组(大文件全同内容行锚定不降级 / 大文件局部差异精确定位 modified 与锚行 unchanged / 多变更组穿插行号全局单调 / 变更组总流超全局上限整体降级 / 跨行重组落在组内锚行 zip 输出);降级组(变更组流超单组上限自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 组流恰好阈值不降级 / 未开启不携带标志)
|
||||
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
||||
- 行切分:结尾换行不产生多余空行
|
||||
|
||||
@@ -127,7 +127,7 @@ src/renderer/src/
|
||||
- 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认
|
||||
- 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失
|
||||
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
|
||||
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
|
||||
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;差异过大(变更组流超限)自动降级并 toast 提示(开关旁常显"已降级"标记);大文件仅局部差异时分块完成不降级且精确定位(无降级提示与标记);导出报告携带字符级说明(含分块语义);降级后报告按实际生效的行级语义描述选项
|
||||
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.5.5",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "DiffLens",
|
||||
"version": "0.5.5",
|
||||
"version": "0.6.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -754,28 +754,52 @@ describe('App - 字符级对比', () => {
|
||||
expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('内容超限时自动降级行级并给出提示', async () => {
|
||||
// 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值);
|
||||
// 总行数 1901 未超过快路径阈值,同步计算即时降级
|
||||
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
|
||||
it('差异过大时自动降级行级并给出提示', async () => {
|
||||
// 单行全差异构造:变更组流 = 25001 + 25001 > 5 万上限;
|
||||
// 总行数 1 未超过快路径阈值,同步计算即时降级
|
||||
window.api = mockApi({
|
||||
openFile: async () => ({
|
||||
path: '/tmp/big.txt',
|
||||
name: 'big.txt',
|
||||
text: big,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
})
|
||||
openFile: async (side) =>
|
||||
side === 'left'
|
||||
? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false }
|
||||
: { path: '/tmp/big2.txt', name: 'big2.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false }
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByText('打开右侧'))
|
||||
await screen.findAllByText('big2.txt')
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument()
|
||||
// 降级状态在工具栏可见:字符级开关旁出现“已降级”标记
|
||||
expect(await screen.findByText('已降级')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => {
|
||||
// 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同:
|
||||
// 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级)
|
||||
const base = Array.from({ length: 2000 }, (_, i) => `line-${i}-${'x'.repeat(40)}`)
|
||||
const modified = [...base]
|
||||
modified[1000] = `line-1000-${'y'.repeat(40)}`
|
||||
window.api = mockApi({
|
||||
openFile: async (side) =>
|
||||
side === 'left'
|
||||
? { path: '/tmp/a.txt', name: 'a.txt', text: base.join('\n'), encoding: 'UTF-8', binary: false }
|
||||
: { path: '/tmp/b.txt', name: 'b.txt', text: modified.join('\n'), encoding: 'UTF-8', binary: false }
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByText('打开右侧'))
|
||||
await screen.findAllByText('b.txt')
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
// 无降级提示与标记(等去抖到期、后台回退同步计算完成)
|
||||
await screen.findByText('有差异')
|
||||
expect(screen.queryByText(/已自动降级/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||
// 差异统计精确:一处修改
|
||||
expect(screen.getByText('~1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('导出报告携带字符级对比说明', async () => {
|
||||
let saved = ''
|
||||
window.api = mockApi({
|
||||
@@ -798,17 +822,17 @@ describe('App - 字符级对比', () => {
|
||||
fireEvent.click(screen.getByText('导出报告'))
|
||||
fireEvent.click(screen.getByText('纯文本'))
|
||||
await screen.findByText(/报告已保存/)
|
||||
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)')
|
||||
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算)')
|
||||
})
|
||||
|
||||
it('降级后导出报告按实际生效的行级语义描述选项', async () => {
|
||||
let saved = ''
|
||||
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
|
||||
// 单行全差异(变更组流超 5 万上限):字符级降级为行级语义
|
||||
window.api = mockApi({
|
||||
openFile: async (side) =>
|
||||
side === 'left'
|
||||
? { path: '/tmp/big.txt', name: 'big.txt', text: big, encoding: 'UTF-8', binary: false }
|
||||
: { path: '/tmp/b.txt', name: 'b.txt', text: 'b', encoding: 'UTF-8', binary: false },
|
||||
? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false }
|
||||
: { path: '/tmp/b.txt', name: 'b.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false },
|
||||
saveReport: async (content: string) => {
|
||||
saved = content
|
||||
return { ok: true, path: 'C:/docs/r.txt' }
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function Toolbar({
|
||||
</label>
|
||||
<label
|
||||
className="switch"
|
||||
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比)"
|
||||
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等);相同内容自动锚定跳过计算,大文件不再整篇受限;差异过大时降级行级对比"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -436,17 +436,23 @@ describe('computeDiff - 字符级对比(差异定位与回映射)', () => {
|
||||
})
|
||||
|
||||
describe('computeDiff - 字符级对比(超限降级)', () => {
|
||||
it('归一化流超限自动降级行级并携带降级标志', () => {
|
||||
const left = 'a'.repeat(100001)
|
||||
const right = 'a'.repeat(100001)
|
||||
it('变更组流超限自动降级行级并携带降级标志', () => {
|
||||
// 单行全差异:组流 = (half+1) × 2 = CHAR_DIFF_MAX_CHARS + 2,超单组上限
|
||||
const half = CHAR_DIFF_MAX_CHARS / 2
|
||||
const left = 'a'.repeat(half + 1)
|
||||
const right = 'b'.repeat(half + 1)
|
||||
const result = computeDiff(left, right, { charMode: true })
|
||||
expect(result.charModeDowngraded).toBe(true)
|
||||
// 降级行级采用“忽略所有空白 + 忽略空行”语义:全 a 单行仍判一致
|
||||
expect(result.summary.changedLines).toBe(0)
|
||||
// 降级行级采用“忽略所有空白 + 忽略空行”语义:单行全不同判 modified
|
||||
const direct = computeDiff(left, right, {
|
||||
ignoreAllWhitespace: true,
|
||||
ignoreBlankLines: true
|
||||
})
|
||||
expect(result.summary).toEqual(direct.summary)
|
||||
})
|
||||
|
||||
it('编辑距离超限快速降级(总量未超但差异过大)', () => {
|
||||
// 内部参数注入小阈值(10):生产默认 3000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造
|
||||
// 内部参数注入小阈值(10):生产默认 5000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造
|
||||
const left = 'a'.repeat(2000)
|
||||
const right = 'b'.repeat(2000)
|
||||
const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 })
|
||||
@@ -467,25 +473,32 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
|
||||
expect(summary.changedLines).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('降级结果与等效行级选项直接计算一致', () => {
|
||||
it('大输入仅局部差异:行锚定后字符级完成,不降级(分块能力回归)', () => {
|
||||
// 旧实现(整篇流 diffChars)此输入必超限降级;分块后相同大行被锚定,
|
||||
// 唯一变更组只有第 2 行(b → c),字符级精确定位
|
||||
const left = 'a'.repeat(100001) + '\nb'
|
||||
const right = 'a'.repeat(100001) + '\nc'
|
||||
const result = computeDiff(left, right, { charMode: true })
|
||||
expect(result.charModeDowngraded).toBe(true)
|
||||
const direct = computeDiff(left, right, {
|
||||
ignoreAllWhitespace: true,
|
||||
ignoreBlankLines: true
|
||||
})
|
||||
expect(result.summary).toEqual(direct.summary)
|
||||
expect(result.charModeDowngraded).toBeUndefined()
|
||||
expect(result.summary.changedLines).toBe(1)
|
||||
expect(result.summary.modified).toBe(1)
|
||||
const m = result.rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.lineNo).toBe(2)
|
||||
expect(m.right.lineNo).toBe(2)
|
||||
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('b'))).toBe(true)
|
||||
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('c'))).toBe(true)
|
||||
// 首行 10 万字符的相同行锚定输出
|
||||
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('恰好阈值不降级', () => {
|
||||
it('变更组流恰好阈值不降级', () => {
|
||||
// 单行组流 = half + half = 恰好 CHAR_DIFF_MAX_CHARS(> 才降),编辑距离 1
|
||||
const half = CHAR_DIFF_MAX_CHARS / 2
|
||||
const { summary, charModeDowngraded } = computeDiff('a'.repeat(half), 'a'.repeat(half), {
|
||||
charMode: true
|
||||
})
|
||||
const left = 'a'.repeat(half - 1) + 'b'
|
||||
const right = 'a'.repeat(half)
|
||||
const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true })
|
||||
expect(charModeDowngraded).toBeUndefined()
|
||||
expect(summary.changedLines).toBe(0)
|
||||
expect(summary.changedLines).toBe(1)
|
||||
})
|
||||
|
||||
it('未开启 charMode 时不携带降级标志', () => {
|
||||
@@ -494,6 +507,91 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 字符级对比(分块锚定)', () => {
|
||||
it('大文件全同内容:行锚定判等,不降级且无差异(归一化流总量远超单组上限)', () => {
|
||||
// 20 万归一化字符(远超 CHAR_DIFF_MAX_CHARS 的 5 万),内容全同
|
||||
const text = Array.from({ length: 2000 }, () => 'a'.repeat(100)).join('\n')
|
||||
const { summary, charModeDowngraded, rows } = computeDiff(text, text, { charMode: true })
|
||||
expect(charModeDowngraded).toBeUndefined()
|
||||
expect(summary.changedLines).toBe(0)
|
||||
expect(rows).toHaveLength(2000)
|
||||
expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true)
|
||||
})
|
||||
|
||||
it('大文件局部差异:差异行 modified 精确定位,其余行锚定 unchanged', () => {
|
||||
const base = Array.from({ length: 1000 }, (_, i) => `line-${i}-content`)
|
||||
const modified = [...base]
|
||||
modified[500] = 'line-500-CONTENT'
|
||||
const { rows, summary, charModeDowngraded } = computeDiff(base.join('\n'), modified.join('\n'), {
|
||||
charMode: true
|
||||
})
|
||||
expect(charModeDowngraded).toBeUndefined()
|
||||
expect(summary.changedLines).toBe(1)
|
||||
expect(summary.modified).toBe(1)
|
||||
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.lineNo).toBe(501)
|
||||
expect(m.right.lineNo).toBe(501)
|
||||
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('content'))).toBe(true)
|
||||
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('CONTENT'))).toBe(true)
|
||||
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(999)
|
||||
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
|
||||
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
|
||||
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
|
||||
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
|
||||
})
|
||||
|
||||
it('多个变更组穿插锚行:各组独立回映射,行号全局单调', () => {
|
||||
const base = Array.from({ length: 30 }, (_, i) => `row${i}`)
|
||||
const modified = [...base]
|
||||
modified[5] = 'row5X'
|
||||
modified[15] = 'row15X'
|
||||
modified[25] = 'row25X'
|
||||
const { rows, summary, charModeDowngraded } = computeDiff(
|
||||
base.join('\n'),
|
||||
modified.join('\n'),
|
||||
{ charMode: true }
|
||||
)
|
||||
expect(charModeDowngraded).toBeUndefined()
|
||||
expect(summary.modified).toBe(3)
|
||||
expect(summary.changedLines).toBe(3)
|
||||
const mods = rows.filter((r) => r.rowKind === 'modified')
|
||||
expect(mods.map((r) => r.left.lineNo)).toEqual([6, 16, 26])
|
||||
expect(mods.map((r) => r.right.lineNo)).toEqual([6, 16, 26])
|
||||
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(27)
|
||||
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
|
||||
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
|
||||
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
|
||||
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
|
||||
})
|
||||
|
||||
it('变更组总流超全局上限仍降级(每组在限内但合计超限)', () => {
|
||||
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1),
|
||||
// 合计 104008 > CHAR_DIFF_GROUP_TOTAL_CHARS(100000)→ 整体降级
|
||||
const build = (withX: boolean): string => {
|
||||
const lines: string[] = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
lines.push(`same-${i}`)
|
||||
lines.push('a'.repeat(6500) + (withX ? 'X' : ''))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
const result = computeDiff(build(false), build(true), { charMode: true })
|
||||
expect(result.charModeDowngraded).toBe(true)
|
||||
})
|
||||
|
||||
it('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => {
|
||||
// 行归一化无交集('ab cd' vs 'ab\ncd')→ 唯一变更组即全文,
|
||||
// 组内流全等 → 锚行输出,行为与旧整篇路径一致
|
||||
const { rows, summary } = computeDiff('ab cd', 'ab\ncd', { charMode: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].left.lineNo).toBe(1)
|
||||
expect(rows[0].right.lineNo).toBe(1)
|
||||
expect(rows[1].left.lineNo).toBeNull()
|
||||
expect(rows[1].right.lineNo).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 字符级对比(字符换位专项)', () => {
|
||||
/**
|
||||
* 已知取舍的固化测试:换位场景(如 ab ↔ ba)下 jsdiff 的 Myers 对齐具有任意性
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { diffArrays, diffChars, diffWordsWithSpace } from 'diff'
|
||||
import { diffArrays, diffChars, diffWordsWithSpace, type Change } from 'diff'
|
||||
|
||||
/** 词级分段类型 */
|
||||
export type SegKind = 'common' | 'insert' | 'delete'
|
||||
@@ -204,7 +204,7 @@ function computeLineDiff(
|
||||
? { left: null, right: null }
|
||||
: wordSegments(l.text, r.text)
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'modified',
|
||||
isChanged: true,
|
||||
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
|
||||
@@ -212,7 +212,7 @@ function computeLineDiff(
|
||||
})
|
||||
} else if (l) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'removed',
|
||||
isChanged: true,
|
||||
left: { lineNo: l.lineNo, text: l.text, segs: null },
|
||||
@@ -220,7 +220,7 @@ function computeLineDiff(
|
||||
})
|
||||
} else if (r) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'added',
|
||||
isChanged: true,
|
||||
left: emptyLine(),
|
||||
@@ -251,7 +251,7 @@ function computeLineDiff(
|
||||
} else {
|
||||
for (const r of addedRows) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'added',
|
||||
isChanged: true,
|
||||
left: emptyLine(),
|
||||
@@ -267,7 +267,7 @@ function computeLineDiff(
|
||||
const lp = leftUsed[leftPtr + i]
|
||||
const rp = rightUsed[rightPtr + i]
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
|
||||
@@ -293,27 +293,36 @@ function computeLineDiff(
|
||||
/** ============ 字符级对比 ============ */
|
||||
|
||||
/**
|
||||
* 字符级对比的字符总量上限(两侧归一化流合计):
|
||||
* diffChars 为 O(ND),字符量与编辑距离任一超限都可能导致耗时失控,
|
||||
* 超限自动降级为行级对比。3 万字符按实测将最坏耗时控制在约 2 秒内。
|
||||
* 字符级对比的字符总量上限(单个变更组两侧归一化流合计):
|
||||
* diffChars 为 O(ND),字符量与编辑距离任一超限都可能导致耗时失控。
|
||||
* 0.6.3 起对比按“行锚定 + 变更组分块”进行:相同内容不进入 diffChars,
|
||||
* 本上限只约束单个变更组——大文件(总量远超上限)只要变更集中在组内限额之下,
|
||||
* 字符级对比即可正常完成,不再整篇受限。
|
||||
*/
|
||||
export const CHAR_DIFF_MAX_CHARS = 30000
|
||||
export const CHAR_DIFF_MAX_CHARS = 50000
|
||||
|
||||
/**
|
||||
* 字符级对比的编辑距离上限:diffChars 以 maxEditLength 迭代轮数封顶,
|
||||
* 编辑距离超限立即放弃(返回 undefined)并降级为行级对比——
|
||||
* 差异过大时字符级高亮已失去可读性,行级语义足够。
|
||||
* 实测 3000 轮 × 3 万字符 ≈ 1.8 秒(worker 内计算,界面不冻结)。
|
||||
* 实测口径:5000 轮 × 5 万字符最坏约 5 秒(worker 内计算,界面不冻结)。
|
||||
*/
|
||||
export const CHAR_DIFF_MAX_EDIT = 3000
|
||||
export const CHAR_DIFF_MAX_EDIT = 5000
|
||||
|
||||
/**
|
||||
* 全部变更组归一化流的总字符上限(两侧合计):兜底“组数多且总量大”的累计耗时
|
||||
* (每组最坏约 5 秒,2 组满载 ≈ 10 秒,在 worker 内计算不冻结界面)。
|
||||
*/
|
||||
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
|
||||
|
||||
/**
|
||||
* 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。
|
||||
* 按 CHAR_DIFF_MAX_CHARS / 4 取值(留 4 倍余量给空白占比)——降级上限之内仍可能出现
|
||||
* 编辑距离极大、O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行),
|
||||
* 按 CHAR_DIFF_MAX_EDIT 对齐取值——同步快路径的最坏耗时 ≈ 流长 × 编辑距离,
|
||||
* 5000 × 5000 ≈ 0.5 秒量级(与 0.5.5 收紧后的口径相当);
|
||||
* 且此类重输入往往行数很少(压缩 JSON / base64 单行),
|
||||
* 行数维度的重输入判定防不住,需以字符量兜底。
|
||||
*/
|
||||
export const CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS / 4
|
||||
export const CHAR_HEAVY_INPUT_CHARS = 5000
|
||||
|
||||
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
|
||||
function isWsChar(ch: string): boolean {
|
||||
@@ -330,21 +339,30 @@ interface CharStream {
|
||||
cols: number[]
|
||||
}
|
||||
|
||||
function buildCharStream(lines: string[], norm: (ch: string) => string): CharStream {
|
||||
/**
|
||||
* 变更组的归一化流:组内各行拼接为一个字符流,
|
||||
* 行号与列号取原始文件位置(供字符差异回映射到行)。
|
||||
* lineIdx 为组内行的 0 基下标序列(左右两侧各自收集)。
|
||||
*/
|
||||
function groupStream(
|
||||
orig: string[],
|
||||
lineIdx: number[],
|
||||
norm: (ch: string) => string
|
||||
): CharStream {
|
||||
const chars: string[] = []
|
||||
const lineNos: number[] = []
|
||||
const cols: number[] = []
|
||||
lines.forEach((text, i) => {
|
||||
for (const li of lineIdx) {
|
||||
let col = 0
|
||||
for (const ch of text) {
|
||||
for (const ch of orig[li]) {
|
||||
if (!isWsChar(ch)) {
|
||||
chars.push(norm(ch))
|
||||
lineNos.push(i + 1)
|
||||
lineNos.push(li + 1)
|
||||
cols.push(col)
|
||||
}
|
||||
col++
|
||||
}
|
||||
})
|
||||
}
|
||||
return { text: chars.join(''), lineNos, cols }
|
||||
}
|
||||
|
||||
@@ -382,17 +400,19 @@ function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符级对比:两侧全文归一化为字符流 → diffChars 对齐 → 字符差异回映射为 DiffRow。
|
||||
* 字符级对比(两阶段:行锚定 + 变更组分块)。
|
||||
*
|
||||
* 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动):
|
||||
* - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行,
|
||||
* 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失);
|
||||
* - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构);
|
||||
* - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
|
||||
* (字符级语义下纯空白行不构成差异)。
|
||||
* 第一阶段:每行剔除全部空白(可选小写)后做行级判等——判等行的归一化字符流必然
|
||||
* 完全一致,是安全锚点,直接输出锚行;相同内容不进入第二阶段的 diffChars,
|
||||
* 因此归一化流总量不再受单次 diffChars 上限约束(大文件字符级不再整篇受限)。
|
||||
*
|
||||
* 内容超限(CHAR_DIFF_MAX_CHARS)或编辑距离超限(CHAR_DIFF_MAX_EDIT,diffChars
|
||||
* 携 maxEditLength 超限返回 undefined)均返回 null,由调用方降级行级。
|
||||
* 第二阶段:连续的 removed/added 行块合为一个“变更组”,组内左右行拼接为归一化
|
||||
* 字符流送 diffChars(差异以字符粒度回映射到行)。约束:单个变更组流不超过
|
||||
* CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT、全部变更组流总量不超过
|
||||
* CHAR_DIFF_GROUP_TOTAL_CHARS——任一超限返回 null,由调用方降级行级(整篇重排或
|
||||
* 大改场景,字符高亮本就失去可读性)。
|
||||
*
|
||||
* 无任何锚行时(两侧行归一化完全无交集)唯一变更组即全文,行为与整篇流对比同构。
|
||||
*/
|
||||
function computeCharDiff(
|
||||
leftText: string,
|
||||
@@ -402,23 +422,126 @@ function computeCharDiff(
|
||||
const leftOrig = splitLines(leftText)
|
||||
const rightOrig = splitLines(rightText)
|
||||
// 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样,
|
||||
// 避免归一化后字符数变化破坏流索引
|
||||
// 避免归一化后字符数变化破坏流索引(第一阶段行判等用整串 toLowerCase,两侧同样
|
||||
// 变换不影响判等一致性;流索引稳定性仍由逐字符守卫保证)
|
||||
const normCh = (ch: string): string => {
|
||||
if (!options.ignoreCase) return ch
|
||||
const lo = ch.toLowerCase()
|
||||
return lo.length === 1 ? lo : ch
|
||||
}
|
||||
const left = buildCharStream(leftOrig, normCh)
|
||||
const right = buildCharStream(rightOrig, normCh)
|
||||
if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null
|
||||
|
||||
// ===== 第一阶段:行归一化锚定 =====
|
||||
const normLine = (s: string): string => {
|
||||
const t = s.replace(/\s+/g, '')
|
||||
return options.ignoreCase ? t.toLowerCase() : t
|
||||
}
|
||||
const lineParts = diffArrays(leftOrig.map(normLine), rightOrig.map(normLine))
|
||||
|
||||
// 输出时间线:锚行(行归一化判等,左右原文各自展示)与变更组占位按序交替
|
||||
const timeline: Array<
|
||||
| { kind: 'anchor'; ln: number; rn: number }
|
||||
| { kind: 'group'; g: number }
|
||||
> = []
|
||||
const groups: { l: number[]; r: number[] }[] = []
|
||||
let lPtr = 0
|
||||
let rPtr = 0
|
||||
for (const part of lineParts) {
|
||||
const len = part.value.length
|
||||
if (!part.added && !part.removed) {
|
||||
for (let i = 0; i < len; i++) {
|
||||
timeline.push({ kind: 'anchor', ln: lPtr + i, rn: rPtr + i })
|
||||
}
|
||||
lPtr += len
|
||||
rPtr += len
|
||||
} else {
|
||||
// 连续 removed/added 行块归为一个变更组(timeline 尾部已是本组则不重复占位)
|
||||
if (groups.length === 0 || timeline[timeline.length - 1].kind !== 'group') {
|
||||
groups.push({ l: [], r: [] })
|
||||
timeline.push({ kind: 'group', g: groups.length - 1 })
|
||||
}
|
||||
const g = groups[groups.length - 1]
|
||||
if (part.removed) {
|
||||
for (let i = 0; i < len; i++) g.l.push(lPtr + i)
|
||||
lPtr += len
|
||||
} else {
|
||||
for (let i = 0; i < len; i++) g.r.push(rPtr + i)
|
||||
rPtr += len
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 第二阶段:变更组字符流 diff(先全部预检,任一超限整体降级)=====
|
||||
const streams = groups.map((g) => ({
|
||||
ls: groupStream(leftOrig, g.l, normCh),
|
||||
rs: groupStream(rightOrig, g.r, normCh),
|
||||
// 组内行号集合(1 基):限定 emitGroupRows 的锚行/尾部输出范围
|
||||
gl: new Set(g.l.map((i) => i + 1)),
|
||||
gr: new Set(g.r.map((i) => i + 1))
|
||||
}))
|
||||
let totalStream = 0
|
||||
for (const { ls, rs } of streams) {
|
||||
totalStream += ls.text.length + rs.text.length
|
||||
}
|
||||
if (totalStream > CHAR_DIFF_GROUP_TOTAL_CHARS) return null
|
||||
const groupParts: Change[][] = []
|
||||
for (const { ls, rs } of streams) {
|
||||
if (ls.text.length + rs.text.length > CHAR_DIFF_MAX_CHARS) return null
|
||||
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
|
||||
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
|
||||
const parts = diffChars(left.text, right.text, {
|
||||
const parts = diffChars(ls.text, rs.text, {
|
||||
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
|
||||
})
|
||||
if (!parts) return null
|
||||
groupParts.push(parts)
|
||||
}
|
||||
|
||||
// ===== 按时间线输出:锚行 + 变更组回映射 =====
|
||||
const rows: DiffRow[] = []
|
||||
for (const item of timeline) {
|
||||
if (item.kind === 'anchor') {
|
||||
rows.push({
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: { lineNo: item.ln + 1, text: leftOrig[item.ln], segs: null },
|
||||
right: { lineNo: item.rn + 1, text: rightOrig[item.rn], segs: null }
|
||||
})
|
||||
} else {
|
||||
const { ls, rs, gl, gr } = streams[item.g]
|
||||
emitGroupRows(ls, rs, leftOrig, rightOrig, groupParts[item.g], rows, gl, gr)
|
||||
}
|
||||
}
|
||||
|
||||
const summary: DiffSummary = {
|
||||
changedLines: rows.filter((r) => r.isChanged).length,
|
||||
inserted: rows.filter((r) => r.rowKind === 'added').length,
|
||||
deleted: rows.filter((r) => r.rowKind === 'removed').length,
|
||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||
}
|
||||
|
||||
return { rows, summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个变更组的字符差异回映射:组内归一化流的 diffChars 结果 → 追加 DiffRow 到 rows。
|
||||
*
|
||||
* 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动):
|
||||
* - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行,
|
||||
* 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失);
|
||||
* - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构);
|
||||
* - 组内对齐块之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
|
||||
* (字符级语义下纯空白行不构成差异)。
|
||||
*/
|
||||
function emitGroupRows(
|
||||
left: CharStream,
|
||||
right: CharStream,
|
||||
leftOrig: string[],
|
||||
rightOrig: string[],
|
||||
parts: Change[],
|
||||
rows: DiffRow[],
|
||||
groupL: Set<number>,
|
||||
groupR: Set<number>
|
||||
): void {
|
||||
// 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构)
|
||||
interface Block {
|
||||
eq: boolean
|
||||
@@ -550,8 +673,6 @@ function computeCharDiff(
|
||||
}
|
||||
}
|
||||
|
||||
let rowSeq = 0
|
||||
const rows: DiffRow[] = []
|
||||
let pendingL: number[] = []
|
||||
let pendingR: number[] = []
|
||||
// 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换)
|
||||
@@ -567,7 +688,7 @@ function computeCharDiff(
|
||||
const rn = pendingR[i]
|
||||
if (ln !== undefined && rn !== undefined) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'modified',
|
||||
isChanged: true,
|
||||
left: {
|
||||
@@ -585,7 +706,7 @@ function computeCharDiff(
|
||||
doneR.add(rn)
|
||||
} else if (ln !== undefined) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'removed',
|
||||
isChanged: true,
|
||||
left: { lineNo: ln, text: leftOrig[ln - 1], segs: null },
|
||||
@@ -594,7 +715,7 @@ function computeCharDiff(
|
||||
doneL.add(ln)
|
||||
} else if (rn !== undefined) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'added',
|
||||
isChanged: true,
|
||||
left: emptyLine(),
|
||||
@@ -614,7 +735,7 @@ function computeCharDiff(
|
||||
const ln = la[i] ?? null
|
||||
const rn = ra[i] ?? null
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
id: `r${rows.length}`,
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null },
|
||||
@@ -623,21 +744,22 @@ function computeCharDiff(
|
||||
}
|
||||
}
|
||||
|
||||
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为文件末行)
|
||||
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为组内末行);
|
||||
// 锚行与尾部范围限定在组内行集合(组外行由时间线的锚行/其他组负责,防止重复输出)
|
||||
let lCursor = 1
|
||||
let rCursor = 1
|
||||
for (const b of blocks) {
|
||||
if (b.eq) {
|
||||
flushChange()
|
||||
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : leftOrig.length
|
||||
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : rightOrig.length
|
||||
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : maxLine(groupL)
|
||||
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : maxLine(groupR)
|
||||
const la: number[] = []
|
||||
for (let ln = lCursor; ln <= lEnd; ln++) {
|
||||
if (!pairedL.has(ln)) la.push(ln)
|
||||
if (groupL.has(ln) && !pairedL.has(ln)) la.push(ln)
|
||||
}
|
||||
const ra: number[] = []
|
||||
for (let rn = rCursor; rn <= rEnd; rn++) {
|
||||
if (!pairedR.has(rn)) ra.push(rn)
|
||||
if (groupR.has(rn) && !pairedR.has(rn)) ra.push(rn)
|
||||
}
|
||||
emitAnchors(la, ra)
|
||||
lCursor = lEnd + 1
|
||||
@@ -663,23 +785,23 @@ function computeCharDiff(
|
||||
}
|
||||
flushChange()
|
||||
|
||||
// 尾部剩余行(最后一块之后的空白行/未覆盖行)按锚行输出
|
||||
// 尾部剩余行(组内未被覆盖的空白行等)按锚行输出(同样限定组内行集合)
|
||||
const tailLa: number[] = []
|
||||
for (let ln = lCursor; ln <= leftOrig.length; ln++) {
|
||||
if (!pairedL.has(ln)) tailLa.push(ln)
|
||||
for (let ln = lCursor; ln <= maxLine(groupL); ln++) {
|
||||
if (groupL.has(ln) && !pairedL.has(ln)) tailLa.push(ln)
|
||||
}
|
||||
const tailRa: number[] = []
|
||||
for (let rn = rCursor; rn <= rightOrig.length; rn++) {
|
||||
if (!pairedR.has(rn)) tailRa.push(rn)
|
||||
for (let rn = rCursor; rn <= maxLine(groupR); rn++) {
|
||||
if (groupR.has(rn) && !pairedR.has(rn)) tailRa.push(rn)
|
||||
}
|
||||
emitAnchors(tailLa, tailRa)
|
||||
|
||||
const summary: DiffSummary = {
|
||||
changedLines: rows.filter((r) => r.isChanged).length,
|
||||
inserted: rows.filter((r) => r.rowKind === 'added').length,
|
||||
deleted: rows.filter((r) => r.rowKind === 'removed').length,
|
||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||
}
|
||||
|
||||
return { rows, summary }
|
||||
/** 行号集合的最大行(空集合为 0,即无锚行可输出) */
|
||||
function maxLine(lines: Set<number>): number {
|
||||
let m = 0
|
||||
for (const ln of lines) {
|
||||
if (ln > m) m = ln
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -187,12 +187,12 @@ describe('plainOptions - 比较选项描述', () => {
|
||||
it('字符级对比单独描述并遮蔽全部空白类选项', () => {
|
||||
expect(
|
||||
plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true })
|
||||
).toBe('字符级对比(忽略全部空白与换行)')
|
||||
).toBe('字符级对比(忽略全部空白与换行,大内容自动分块计算)')
|
||||
})
|
||||
|
||||
it('字符级对比与忽略大小写组合列出', () => {
|
||||
expect(plainOptions({ charMode: true, ignoreCase: true })).toBe(
|
||||
'字符级对比(忽略全部空白与换行)、忽略大小写'
|
||||
'字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,9 +120,10 @@ export function plainSummary(summary: DiffSummary): string {
|
||||
|
||||
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
||||
export function plainOptions(options?: DiffOptions): string {
|
||||
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项
|
||||
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项;
|
||||
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束
|
||||
if (options?.charMode) {
|
||||
const on = ['字符级对比(忽略全部空白与换行)']
|
||||
const on = ['字符级对比(忽略全部空白与换行,大内容自动分块计算)']
|
||||
if (options.ignoreCase) on.push('忽略大小写')
|
||||
return on.join('、')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useDiff } from './useDiff'
|
||||
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
|
||||
import { computeDiff, CHAR_HEAVY_INPUT_CHARS, type DiffResult, type DiffOptions } from '../diff/diffEngine'
|
||||
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
|
||||
|
||||
/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
|
||||
@@ -76,7 +76,7 @@ describe('useDiff - 快路径', () => {
|
||||
describe('useDiff - 字符级大字符量重路径', () => {
|
||||
it('少行大字符量输入(charMode 开启)进入重路径后台计算', () => {
|
||||
const factory = makeFactory()
|
||||
// 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS
|
||||
// 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS(5000)
|
||||
const big = 'a'.repeat(30000) + '\n' + 'b'.repeat(30000)
|
||||
const { result } = renderHook(() => useDiff(big, big, CHAR_ON, factory))
|
||||
expect(result.current.computing).toBe(true)
|
||||
@@ -116,12 +116,12 @@ describe('useDiff - 字符级大字符量重路径', () => {
|
||||
|
||||
it('恰好阈值不触发重路径,超过阈值触发', () => {
|
||||
const factory = makeFactory()
|
||||
// 两侧各 3750 字符 = 恰好 7500(CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS/4):不触发
|
||||
const at = 'a'.repeat(3750)
|
||||
// 两侧各 CHAR_HEAVY_INPUT_CHARS / 2 = 恰好 5000:不触发
|
||||
const at = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2)
|
||||
const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory))
|
||||
expect(hook1.result.current.computing).toBe(false)
|
||||
// 3751 + 3750 = 7501:触发
|
||||
const over = 'a'.repeat(3751)
|
||||
// 一侧超出 1 字符(5001 + 5000):触发
|
||||
const over = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2 + 1)
|
||||
const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory))
|
||||
expect(hook2.result.current.computing).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user