feat: v0.4.3 忽略所有空白比较选项与报告选项说明 - 新增忽略所有空白开关(行内空格/制表符/全角空格不参与判等、展示仍为原文、换行结构仍参与对比)、三种报告头部新增比较选项说明行(HTML 为 Hero 区徽标)

This commit is contained in:
2026-08-18 10:03:28 +08:00
parent fe445598c9
commit e639dfe88d
12 changed files with 229 additions and 17 deletions
+5 -3
View File
@@ -94,7 +94,8 @@ export default function App(): ReactElement {
const [options, setOptions] = useState<DiffOptions>({
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false
ignoreBlankLines: false,
ignoreAllWhitespace: false
})
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState(false)
@@ -319,7 +320,8 @@ export default function App(): ReactElement {
leftEncoding: paneL?.meta.encoding ?? '',
rightEncoding: paneR?.meta.encoding ?? '',
leftPath: paneL?.path || undefined,
rightPath: paneR?.path || undefined
rightPath: paneR?.path || undefined,
options
}
const content = buildReport(rows, ctx, summary, 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
+45
View File
@@ -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 差异导航快捷键', () => {
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
render(<App />)
+10 -4
View File
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
const onSwap = vi.fn()
const utils = render(
<Toolbar
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }}
onOptionsChange={onOptionsChange}
summary={summary}
navIndex={0}
@@ -40,19 +40,25 @@ describe('Toolbar', () => {
it('切换忽略大小写触发 onOptionsChange', () => {
const { onOptionsChange } = setup()
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', () => {
const { onOptionsChange } = setup()
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', () => {
const { onOptionsChange } = setup()
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)', () => {
+11
View File
@@ -76,6 +76,17 @@ export default function Toolbar({
/>
</label>
<label
className="switch"
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
>
<input
type="checkbox"
checked={options.ignoreAllWhitespace ?? false}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/>
</label>
</div>
<div className="tool-group">
+70
View File
@@ -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 - 行切分细节', () => {
it('结尾换行不产生多余空行', () => {
const { rows, summary } = computeDiff('a\n', 'a')
+6 -2
View File
@@ -41,6 +41,8 @@ export interface DiffOptions {
ignoreCase?: boolean
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
ignoreBlankLines?: boolean
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
ignoreAllWhitespace?: boolean
}
export interface DiffResult {
@@ -94,7 +96,7 @@ export function splitLines(text: string): string[] {
* 计算文本差异。
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
* 删除段与新增段配对为“修改”行并做词级内联高亮。
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
* trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
*/
export function computeDiff(
@@ -117,7 +119,9 @@ export function computeDiff(
: rightSeq
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()
return v
}
+50
View File
@@ -7,6 +7,7 @@ import {
buildReport,
buildSegments,
plainSummary,
plainOptions,
REPORT_EXT
} 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', () => {
it('输出带增删改标记与人话结论的行式文本', () => {
const { rows, summary } = computeDiff('a', 'a\nb')
+23 -2
View File
@@ -1,4 +1,4 @@
import { DiffRow, DiffSummary, SideCell } from './diffEngine'
import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine'
export type ReportFormat = 'html' | 'txt' | 'md'
@@ -15,6 +15,8 @@ export interface ReportContext {
leftPath?: string
/** 右侧文件完整路径(可选) */
rightPath?: string
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
options?: DiffOptions
}
const esc = (s: string): string =>
@@ -116,6 +118,17 @@ export function plainSummary(summary: DiffSummary): string {
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 = (
ctx: ReportContext,
summary: DiffSummary,
@@ -128,6 +141,7 @@ const headLines = (
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`比较选项:${plainOptions(ctx.options)}`,
`结论:${plainSummary(summary)}`,
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}`
]
@@ -165,6 +179,11 @@ const REPORT_CSS = `
-webkit-background-clip:text; background-clip:text; color:transparent;
}
.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 {
font-size:16px; font-weight:700; color:#f6bd87;
padding:8px 14px; border-radius:10px; margin-bottom:14px;
@@ -311,7 +330,8 @@ const REPORT_CSS = `
@media print {
body { background:#fff; color:#111; padding:0; }
.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.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
@@ -453,6 +473,7 @@ export function buildHtmlReport(
<header class="hero">
<div class="hero-title">🔍 DiffLens 差异报告</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="files">
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}