feat: v0.4.3 忽略所有空白比较选项与报告选项说明 - 新增忽略所有空白开关(行内空格/制表符/全角空格不参与判等、展示仍为原文、换行结构仍参与对比)、三种报告头部新增比较选项说明行(HTML 为 Hero 区徽标)
This commit is contained in:
@@ -17,7 +17,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
|
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
|
||||||
- **交换左右侧**:一键互换两侧内容与视角
|
- **交换左右侧**:一键互换两侧内容与视角
|
||||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
||||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||||
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
@@ -96,7 +96,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.4.2
|
- 版本:0.4.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
|||||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||||
|
|
||||||
当前基线版本:**`0.4.2`**
|
当前基线版本:**`0.4.3`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +68,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
||||||
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
||||||
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
||||||
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(当前):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
||||||
|
0.4.3 ← 忽略所有空白比较选项与报告选项说明(当前):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
|
||||||
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
+3
-1
@@ -77,7 +77,7 @@ src/renderer/src/
|
|||||||
- 完全一致 → 全部 `unchanged`,无差异
|
- 完全一致 → 全部 `unchanged`,无差异
|
||||||
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||||
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||||
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines` 生效与关闭
|
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
||||||
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
||||||
- 行切分:结尾换行不产生多余空行
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@ src/renderer/src/
|
|||||||
- Markdown:表格化、管道符转义保持表格结构、引用块含人话结论
|
- Markdown:表格化、管道符转义保持表格结构、引用块含人话结论
|
||||||
- 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context
|
- 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context
|
||||||
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
|
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
|
||||||
|
- 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行
|
||||||
- 格式分发与扩展名映射(`REPORT_EXT`)
|
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||||
|
|
||||||
### 5.3 文本工具(`textUtils.ts`)
|
### 5.3 文本工具(`textUtils.ts`)
|
||||||
@@ -107,6 +108,7 @@ src/renderer/src/
|
|||||||
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
||||||
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
||||||
- 交换左右侧后面板文件互换
|
- 交换左右侧后面板文件互换
|
||||||
|
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
||||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||||
|
|
||||||
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`)
|
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.4.2",
|
"version": "0.4.3",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export default function App(): ReactElement {
|
|||||||
const [options, setOptions] = useState<DiffOptions>({
|
const [options, setOptions] = useState<DiffOptions>({
|
||||||
trimWhitespace: false,
|
trimWhitespace: false,
|
||||||
ignoreCase: false,
|
ignoreCase: false,
|
||||||
ignoreBlankLines: false
|
ignoreBlankLines: false,
|
||||||
|
ignoreAllWhitespace: false
|
||||||
})
|
})
|
||||||
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||||
const [onlyDiff, setOnlyDiff] = useState(false)
|
const [onlyDiff, setOnlyDiff] = useState(false)
|
||||||
@@ -319,7 +320,8 @@ export default function App(): ReactElement {
|
|||||||
leftEncoding: paneL?.meta.encoding ?? '',
|
leftEncoding: paneL?.meta.encoding ?? '',
|
||||||
rightEncoding: paneR?.meta.encoding ?? '',
|
rightEncoding: paneR?.meta.encoding ?? '',
|
||||||
leftPath: paneL?.path || undefined,
|
leftPath: paneL?.path || undefined,
|
||||||
rightPath: paneR?.path || undefined
|
rightPath: paneR?.path || undefined,
|
||||||
|
options
|
||||||
}
|
}
|
||||||
const content = buildReport(rows, ctx, summary, fmt)
|
const content = buildReport(rows, ctx, summary, fmt)
|
||||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||||
@@ -338,7 +340,7 @@ export default function App(): ReactElement {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[diff, rows, paneL, paneR, summary, showToast]
|
[diff, rows, paneL, paneR, summary, options, showToast]
|
||||||
)
|
)
|
||||||
|
|
||||||
const anyPane = paneL !== null || paneR !== null
|
const anyPane = paneL !== null || paneR !== null
|
||||||
|
|||||||
@@ -490,6 +490,51 @@ describe('App - 仅看差异视图', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('App - 忽略所有空白', () => {
|
||||||
|
it('开启后仅行内空白不同的两侧文本判为完全一致', async () => {
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/a.js', name: 'a.js', text: 'const x = 1;\nif (x) {\n doSomething( x )\n}', encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/b.js', name: 'b.js', text: 'const x = 1;\nif(x){\n\tdoSomething(x)\n}', encoding: 'UTF-8', binary: false }
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('b.js')
|
||||||
|
// 默认严格对比:报差异
|
||||||
|
expect(screen.getByText('有差异')).toBeInTheDocument()
|
||||||
|
// 开启忽略所有空白后:判为完全一致
|
||||||
|
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||||
|
expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导出报告携带比较选项说明', async () => {
|
||||||
|
let saved = ''
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async () => ({
|
||||||
|
path: '/tmp/a.txt',
|
||||||
|
name: 'a.txt',
|
||||||
|
text: 'line1\nline2',
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
binary: false
|
||||||
|
}),
|
||||||
|
saveReport: async (content: string) => {
|
||||||
|
saved = content
|
||||||
|
return { ok: true, path: 'C:/docs/r.txt' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
fireEvent.click(screen.getByText('纯文本'))
|
||||||
|
await screen.findByText(/报告已保存/)
|
||||||
|
expect(saved).toContain('比较选项:无(严格逐字符对比)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
||||||
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
|
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
|
|||||||
const onSwap = vi.fn()
|
const onSwap = vi.fn()
|
||||||
const utils = render(
|
const utils = render(
|
||||||
<Toolbar
|
<Toolbar
|
||||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
|
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }}
|
||||||
onOptionsChange={onOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
navIndex={0}
|
navIndex={0}
|
||||||
@@ -40,19 +40,25 @@ describe('Toolbar', () => {
|
|||||||
it('切换忽略大小写触发 onOptionsChange', () => {
|
it('切换忽略大小写触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略空行触发 onOptionsChange', () => {
|
it('切换忽略空行触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略空行'))
|
fireEvent.click(screen.getByLabelText('忽略空行'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换忽略所有空白触发 onOptionsChange', () => {
|
||||||
|
const { onOptionsChange } = setup()
|
||||||
|
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||||
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ export default function Toolbar({
|
|||||||
/>
|
/>
|
||||||
忽略空行
|
忽略空行
|
||||||
</label>
|
</label>
|
||||||
|
<label
|
||||||
|
className="switch"
|
||||||
|
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.ignoreAllWhitespace ?? false}
|
||||||
|
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
|
||||||
|
/>
|
||||||
|
忽略所有空白
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tool-group">
|
<div className="tool-group">
|
||||||
|
|||||||
@@ -172,6 +172,76 @@ describe('computeDiff - 超长行词级防护', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 忽略所有空白', () => {
|
||||||
|
it('行内空格差异不再产生差异', () => {
|
||||||
|
const { summary } = computeDiff('a b c', 'abc', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('制表符与全角空格同样被忽略', () => {
|
||||||
|
expect(computeDiff('a\tb', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
|
||||||
|
expect(computeDiff('a b', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开启后行首尾空白差异自然被覆盖(trim 子集)', () => {
|
||||||
|
const { summary } = computeDiff(' value ', 'value', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缩进与符号间空白差异判等(代码场景)', () => {
|
||||||
|
const { summary } = computeDiff(
|
||||||
|
' if (x) { return 1; }',
|
||||||
|
'if(x){return 1;}',
|
||||||
|
{ ignoreAllWhitespace: true }
|
||||||
|
)
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多行文本仅行内空白不同判为一致', () => {
|
||||||
|
const { summary } = computeDiff('a b\nc d\ne', 'ab\nc d\ne', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('实际文字差异仍被识别且保留词级高亮', () => {
|
||||||
|
const { rows, summary } = computeDiff('a b', 'a c', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.left.segs).not.toBeNull()
|
||||||
|
expect(m.right.segs).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示文本始终为原始行(不因归一化而改写)', () => {
|
||||||
|
const { rows } = computeDiff(' a b ', 'a b', { ignoreAllWhitespace: true })
|
||||||
|
expect(rows[0].left.text).toBe(' a b ')
|
||||||
|
expect(rows[0].right.text).toBe('a b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('换行结构差异不受该选项影响(行级对比边界)', () => {
|
||||||
|
const { summary } = computeDiff('ab cd', 'ab\ncd', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.changedLines).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空行数量差异不受该选项影响(需配合忽略空行)', () => {
|
||||||
|
const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true })
|
||||||
|
expect(summary.deleted).toBe(1)
|
||||||
|
const both = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true, ignoreBlankLines: true })
|
||||||
|
expect(both.summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('与忽略大小写组合生效', () => {
|
||||||
|
const { summary } = computeDiff('A B', 'ab', {
|
||||||
|
ignoreAllWhitespace: true,
|
||||||
|
ignoreCase: true
|
||||||
|
})
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开启时行内空格差异仍被识别', () => {
|
||||||
|
const { summary } = computeDiff('a b c', 'abc')
|
||||||
|
expect(summary.changedLines).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('computeDiff - 行切分细节', () => {
|
describe('computeDiff - 行切分细节', () => {
|
||||||
it('结尾换行不产生多余空行', () => {
|
it('结尾换行不产生多余空行', () => {
|
||||||
const { rows, summary } = computeDiff('a\n', 'a')
|
const { rows, summary } = computeDiff('a\n', 'a')
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface DiffOptions {
|
|||||||
ignoreCase?: boolean
|
ignoreCase?: boolean
|
||||||
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
|
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
|
||||||
ignoreBlankLines?: boolean
|
ignoreBlankLines?: boolean
|
||||||
|
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
|
||||||
|
ignoreAllWhitespace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffResult {
|
export interface DiffResult {
|
||||||
@@ -94,7 +96,7 @@ export function splitLines(text: string): string[] {
|
|||||||
* 计算文本差异。
|
* 计算文本差异。
|
||||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||||
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
|
* trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
|
||||||
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
||||||
*/
|
*/
|
||||||
export function computeDiff(
|
export function computeDiff(
|
||||||
@@ -117,7 +119,9 @@ export function computeDiff(
|
|||||||
: rightSeq
|
: rightSeq
|
||||||
|
|
||||||
const norm = (s: string): string => {
|
const norm = (s: string): string => {
|
||||||
let v = options.trimWhitespace ? s.trim() : s
|
// 忽略所有空白为 trimWhitespace 的超集:开启后行内空白一并剔除,trim 不再重复处理
|
||||||
|
let v = options.ignoreAllWhitespace ? s.replace(/\s+/g, '') : s
|
||||||
|
if (!options.ignoreAllWhitespace && options.trimWhitespace) v = v.trim()
|
||||||
if (options.ignoreCase) v = v.toLowerCase()
|
if (options.ignoreCase) v = v.toLowerCase()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
buildReport,
|
buildReport,
|
||||||
buildSegments,
|
buildSegments,
|
||||||
plainSummary,
|
plainSummary,
|
||||||
|
plainOptions,
|
||||||
REPORT_EXT
|
REPORT_EXT
|
||||||
} from './report'
|
} from './report'
|
||||||
|
|
||||||
@@ -164,6 +165,55 @@ describe('buildHtmlReport', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('plainOptions - 比较选项描述', () => {
|
||||||
|
it('未传或空选项时说明为严格对比', () => {
|
||||||
|
expect(plainOptions(undefined)).toBe('无(严格逐字符对比)')
|
||||||
|
expect(plainOptions({})).toBe('无(严格逐字符对比)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列出全部生效选项', () => {
|
||||||
|
expect(
|
||||||
|
plainOptions({ ignoreAllWhitespace: true, ignoreCase: true, ignoreBlankLines: true })
|
||||||
|
).toBe('忽略所有空白、忽略大小写、忽略空行')
|
||||||
|
expect(plainOptions({ trimWhitespace: true, ignoreCase: true })).toBe(
|
||||||
|
'忽略行首尾空白、忽略大小写'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => {
|
||||||
|
expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('报告头部比较选项说明', () => {
|
||||||
|
const { rows, summary } = computeDiff('a b', 'ab', { ignoreAllWhitespace: true })
|
||||||
|
const optCtx = {
|
||||||
|
...ctx,
|
||||||
|
options: { ignoreAllWhitespace: true, ignoreCase: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('纯文本头部包含比较选项行', () => {
|
||||||
|
const txt = buildTxtReport(rows, optCtx, summary)
|
||||||
|
expect(txt).toContain('比较选项:忽略所有空白、忽略大小写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Markdown 头部包含比较选项行(引用块内)', () => {
|
||||||
|
const md = buildMarkdownReport(rows, optCtx, summary)
|
||||||
|
expect(md).toContain('> 比较选项:忽略所有空白、忽略大小写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('HTML Hero 区包含比较选项徽标', () => {
|
||||||
|
const html = buildHtmlReport(rows, optCtx, summary)
|
||||||
|
expect(html).toContain('hero-opts')
|
||||||
|
expect(html).toContain('比较选项:忽略所有空白、忽略大小写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未传选项时头部说明为严格对比', () => {
|
||||||
|
const txt = buildTxtReport(rows, ctx, summary)
|
||||||
|
expect(txt).toContain('比较选项:无(严格逐字符对比)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('buildTxtReport', () => {
|
describe('buildTxtReport', () => {
|
||||||
it('输出带增删改标记与人话结论的行式文本', () => {
|
it('输出带增删改标记与人话结论的行式文本', () => {
|
||||||
const { rows, summary } = computeDiff('a', 'a\nb')
|
const { rows, summary } = computeDiff('a', 'a\nb')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DiffRow, DiffSummary, SideCell } from './diffEngine'
|
import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine'
|
||||||
|
|
||||||
export type ReportFormat = 'html' | 'txt' | 'md'
|
export type ReportFormat = 'html' | 'txt' | 'md'
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ export interface ReportContext {
|
|||||||
leftPath?: string
|
leftPath?: string
|
||||||
/** 右侧文件完整路径(可选) */
|
/** 右侧文件完整路径(可选) */
|
||||||
rightPath?: string
|
rightPath?: string
|
||||||
|
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
|
||||||
|
options?: DiffOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
@@ -116,6 +118,17 @@ export function plainSummary(summary: DiffSummary): string {
|
|||||||
return `共 ${summary.changedLines} 处不同:${parts.join('、')}`
|
return `共 ${summary.changedLines} 处不同:${parts.join('、')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
||||||
|
export function plainOptions(options?: DiffOptions): string {
|
||||||
|
const on: string[] = []
|
||||||
|
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
|
||||||
|
if (options?.ignoreAllWhitespace) on.push('忽略所有空白')
|
||||||
|
else if (options?.trimWhitespace) on.push('忽略行首尾空白')
|
||||||
|
if (options?.ignoreCase) on.push('忽略大小写')
|
||||||
|
if (options?.ignoreBlankLines) on.push('忽略空行')
|
||||||
|
return on.length > 0 ? on.join('、') : '无(严格逐字符对比)'
|
||||||
|
}
|
||||||
|
|
||||||
const headLines = (
|
const headLines = (
|
||||||
ctx: ReportContext,
|
ctx: ReportContext,
|
||||||
summary: DiffSummary,
|
summary: DiffSummary,
|
||||||
@@ -128,6 +141,7 @@ const headLines = (
|
|||||||
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
||||||
}`,
|
}`,
|
||||||
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||||
|
`比较选项:${plainOptions(ctx.options)}`,
|
||||||
`结论:${plainSummary(summary)}`,
|
`结论:${plainSummary(summary)}`,
|
||||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
||||||
]
|
]
|
||||||
@@ -165,6 +179,11 @@ const REPORT_CSS = `
|
|||||||
-webkit-background-clip:text; background-clip:text; color:transparent;
|
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||||
}
|
}
|
||||||
.hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; }
|
.hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; }
|
||||||
|
.hero-opts {
|
||||||
|
color:#74809a; font-size:12px; margin:0 0 10px;
|
||||||
|
padding:4px 10px; border-radius:8px; display:inline-block;
|
||||||
|
background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.14);
|
||||||
|
}
|
||||||
.verdict {
|
.verdict {
|
||||||
font-size:16px; font-weight:700; color:#f6bd87;
|
font-size:16px; font-weight:700; color:#f6bd87;
|
||||||
padding:8px 14px; border-radius:10px; margin-bottom:14px;
|
padding:8px 14px; border-radius:10px; margin-bottom:14px;
|
||||||
@@ -311,7 +330,8 @@ const REPORT_CSS = `
|
|||||||
@media print {
|
@media print {
|
||||||
body { background:#fff; color:#111; padding:0; }
|
body { background:#fff; color:#111; padding:0; }
|
||||||
.hero { border-color:#ccc; background:#fafafa; box-shadow:none; }
|
.hero { border-color:#ccc; background:#fafafa; box-shadow:none; }
|
||||||
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
|
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
|
||||||
|
.hero-opts { background:#fafafa; border-color:#ddd; color:#666; }
|
||||||
.verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
|
.verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
|
||||||
.verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
|
.verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
|
||||||
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
|
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
|
||||||
@@ -453,6 +473,7 @@ export function buildHtmlReport(
|
|||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
||||||
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
||||||
|
<div class="hero-opts">比较选项:${esc(plainOptions(ctx.options))}</div>
|
||||||
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
||||||
<div class="files">
|
<div class="files">
|
||||||
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
||||||
|
|||||||
Reference in New Issue
Block a user