fix: v0.3.2 报告对齐与性能收尾 - 纯文本报告行号右对齐、超长行列宽封顶防卡顿、对话框与拖拽扩展名清单对齐、右键菜单打开时导航快捷键守卫、清理导出死属性

This commit is contained in:
2026-08-17 22:49:15 +08:00
parent 3406141b3c
commit 1d1b92e624
15 changed files with 105 additions and 30 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.3.1 - 版本:0.3.2
--- ---
+3 -2
View File
@@ -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.3.1`** 当前基线版本:**`0.3.2`**
--- ---
@@ -64,7 +64,8 @@ z = 补丁版本号(Patch
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
0.3.1 ← 稳健性与测试补强(当前):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) 0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
+3 -3
View File
@@ -76,19 +76,19 @@ src/renderer/src/
### 5.2 报告生成(`report.ts` ### 5.2 报告生成(`report.ts`
- HTML:输出合法文档、含文件名/统计/类型高亮语义 - HTML:输出合法文档、含文件名/统计/类型高亮语义
- 纯文本:含增删改标记(`+ -` - 纯文本:含增删改标记(`+ -`、行号按最大位数右对齐
- Markdown:表格化、管道符转义保持表格结构 - Markdown:表格化、管道符转义保持表格结构
- 格式分发与扩展名映射(`REPORT_EXT` - 格式分发与扩展名映射(`REPORT_EXT`
### 5.3 文本工具(`textUtils.ts` ### 5.3 文本工具(`textUtils.ts`
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感 - 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
- `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF) - `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF)
- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 - `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 / 传 cap 时达上限提前返回
### 5.4 对比视图(`DiffView.tsx` ### 5.4 对比视图(`DiffView.tsx`
- 渲染:行内容、行号、词级高亮段、行尾空白标注 - 渲染:行内容、行号、词级高亮段、行尾空白标注
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽);探针未测得时不设置 - 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置
- 折叠提示行:默认文案与自定义文案(仅看差异视图) - 折叠提示行:默认文案与自定义文案(仅看差异视图)
- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo` - 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.3.1", "version": "0.3.2",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+3 -2
View File
@@ -94,8 +94,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
properties: ['openFile'], properties: ['openFile'],
filters: [ 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'] }, // 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步
// 兜底入口:拖拽白名单比对话框筛选项更全(svelte/tsv/lock 等),补“所有文件”消除两种入口的不一致 { name: '文本文件', extensions: ['txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'] },
// 兜底入口:允许打开任意扩展名文件
{ name: '所有文件', extensions: ['*'] } { name: '所有文件', extensions: ['*'] }
] ]
}) })
+3 -4
View File
@@ -266,17 +266,17 @@ export default function App(): ReactElement {
[changedRows, navIndex] [changedRows, navIndex]
) )
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占 // 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗或右键菜单打开时不抢占
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'F7') return if (e.key !== 'F7') return
if (pasteSide !== null) return if (pasteSide !== null || menu !== null) return
e.preventDefault() e.preventDefault()
go(e.shiftKey ? -1 : 1) go(e.shiftKey ? -1 : 1)
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [go, pasteSide]) }, [go, pasteSide, menu])
// 行级右键菜单 // 行级右键菜单
const onRowContext = useCallback( const onRowContext = useCallback(
@@ -438,7 +438,6 @@ export default function App(): ReactElement {
navCount={changedRows.length} navCount={changedRows.length}
onNav={go} onNav={go}
onExport={(fmt) => void exportReport(fmt)} onExport={(fmt) => void exportReport(fmt)}
canExport={anyPane}
onlyDiff={onlyDiff} onlyDiff={onlyDiff}
onOnlyDiffChange={setOnlyDiff} onOnlyDiffChange={setOnlyDiff}
onSwap={swapPanes} onSwap={swapPanes}
+11
View File
@@ -491,6 +491,17 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => {
// 导航未被触发,计数保持 1 / 2 // 导航未被触发,计数保持 1 / 2
expect(screen.getByText('1 / 2')).toBeInTheDocument() expect(screen.getByText('1 / 2')).toBeInTheDocument()
}) })
it('右键菜单打开时 F7 不触发导航', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('1 / 2')
fireEvent.contextMenu((await screen.findAllByText(/line1/))[0])
expect(await screen.findByText('复制左侧此行内容')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'F7' })
// 导航未被触发,计数保持 1 / 2
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
}) })
describe('App - 交换左右侧', () => { describe('App - 交换左右侧', () => {
@@ -254,6 +254,32 @@ describe('DiffView - 虚拟滚动', () => {
expect(columns[0].style.minWidth).toBe('') expect(columns[0].style.minWidth).toBe('')
expect(columns[1].style.minWidth).toBe('') expect(columns[1].style.minWidth).toBe('')
}) })
it('超长行列宽封顶,minWidth 不随行长无限增长', () => {
// 探针 mock 为 10 字符 70px → 每字符 7px;列宽封顶 5000 → 5000×7+86
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 longRow: DiffRow = {
id: 'long0',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'a'.repeat(200000), segs: null },
right: { lineNo: 1, text: 'b', segs: null }
}
const { container } = renderDiff([longRow])
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
expect(columns[0].style.minWidth).toBe('35086px')
rectSpy.mockRestore()
})
}) })
describe('DiffView - 折叠提示行', () => { describe('DiffView - 折叠提示行', () => {
+3 -1
View File
@@ -213,13 +213,15 @@ export default function DiffView({
if (w > 0) setCharW(w / 10) if (w > 0) setCharW(w / 10)
}, []) }, [])
/** 列宽计算封顶:超长行(base64/压缩产物可达数 MB 单行)达到上限即停,避免逐字符全量遍历;与词级防护阈值同源 */
const MAX_COLS_CAP = 5000
const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => { const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => {
let max = 40 let max = 40
for (const it of rows) { for (const it of rows) {
if (isFold(it)) continue if (isFold(it)) continue
const t = pick(it).text const t = pick(it).text
if (t) { if (t) {
const c = displayCols(t) const c = displayCols(t, MAX_COLS_CAP)
if (c > max) max = c if (c > max) max = c
} }
} }
+1 -2
View File
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
function setup(extra?: { canExport?: boolean }) { function setup() {
const onOptionsChange = vi.fn() const onOptionsChange = vi.fn()
const onNav = vi.fn() const onNav = vi.fn()
const onExport = vi.fn() const onExport = vi.fn()
@@ -20,7 +20,6 @@ function setup(extra?: { canExport?: boolean }) {
navCount={3} navCount={3}
onNav={onNav} onNav={onNav}
onExport={onExport} onExport={onExport}
canExport={extra?.canExport ?? true}
onlyDiff={false} onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange} onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap} onSwap={onSwap}
+1 -7
View File
@@ -17,7 +17,6 @@ interface ToolbarProps {
navCount: number navCount: number
onNav: (dir: 1 | -1) => void onNav: (dir: 1 | -1) => void
onExport: (fmt: ReportFormat) => void onExport: (fmt: ReportFormat) => void
canExport: boolean
/** 仅看差异视图开关 */ /** 仅看差异视图开关 */
onlyDiff: boolean onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void onOnlyDiffChange: (v: boolean) => void
@@ -33,7 +32,6 @@ export default function Toolbar({
navCount, navCount,
onNav, onNav,
onExport, onExport,
canExport,
onlyDiff, onlyDiff,
onOnlyDiffChange, onOnlyDiffChange,
onSwap onSwap
@@ -115,11 +113,7 @@ export default function Toolbar({
</div> </div>
<div className="tool-group export-wrap" ref={exportWrapRef}> <div className="tool-group export-wrap" ref={exportWrapRef}>
<button <button className="btn primary" onClick={() => setExportOpen((o) => !o)}>
className="btn primary"
disabled={!canExport}
onClick={() => setExportOpen((o) => !o)}
>
</button> </button>
{exportOpen && ( {exportOpen && (
+16
View File
@@ -29,6 +29,22 @@ describe('buildTxtReport', () => {
expect(txt).toContain('DiffLens') expect(txt).toContain('DiffLens')
expect(txt).toContain('+') expect(txt).toContain('+')
}) })
it('行号按最大位数右对齐填充', () => {
const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n')
const right = left.replace('l1', 'changed')
const { rows, summary } = computeDiff(left, right)
const txt = buildTxtReport(rows, ctx, summary)
// 最大行号 12 → 宽度 2:行号 2 左补空格,行号 12 原样(修改行左右行号同样填充)
expect(txt).toContain('~ 2 | l1 ==> 2 | changed')
expect(txt).toContain(' 12 | l11')
})
it('行号位数一致时无额外填充', () => {
const { rows, summary } = computeDiff('a\nc', 'a\nb')
const txt = buildTxtReport(rows, ctx, summary)
expect(txt).toContain('~ 2 | c ==> 2 | b')
})
}) })
describe('buildMarkdownReport', () => { describe('buildMarkdownReport', () => {
+17 -6
View File
@@ -20,9 +20,11 @@ export interface ReportContext {
const esc = (s: string): string => const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 格式化为固定宽行号(右对齐) */ /** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */
function padNo(n: number | null): string { function padNo(n: number | null, width?: number): string {
return n === null ? '' : String(n) if (n === null) return ''
const s = String(n)
return width !== undefined ? s.padStart(width) : s
} }
/** 单元格文本(词级高亮段在 HTML 里渲染为 span) */ /** 单元格文本(词级高亮段在 HTML 里渲染为 span) */
@@ -140,16 +142,25 @@ export function buildTxtReport(
const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======') const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======')
const lines = [...head, '----------------------------------'] const lines = [...head, '----------------------------------']
// 行号列宽取全部行号的最大位数,右对齐填充保证各行文本起始列一致
let maxNo = 0
for (const row of rows) {
if (row.left.lineNo !== null && row.left.lineNo > maxNo) maxNo = row.left.lineNo
if (row.right.lineNo !== null && row.right.lineNo > maxNo) maxNo = row.right.lineNo
}
const width = String(maxNo).length
for (const row of rows) { for (const row of rows) {
const tag = typeTag(row.rowKind) const tag = typeTag(row.rowKind)
if (row.rowKind === 'modified') { if (row.rowKind === 'modified') {
// 修改行:左/右并置 // 修改行:左/右并置
const l = padNo(row.left.lineNo) || ' ' const l = padNo(row.left.lineNo, width)
const r = padNo(row.right.lineNo) || ' ' const r = padNo(row.right.lineNo, width)
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`) lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
} else { } else {
const no = padNo( const no = padNo(
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo,
width
) )
const text = row.rowKind === 'added' ? row.right.text : row.left.text const text = row.rowKind === 'added' ? row.right.text : row.left.text
lines.push(`${tag} ${no} | ${text ?? ''}`) lines.push(`${tag} ${no} | ${text ?? ''}`)
+13
View File
@@ -68,4 +68,17 @@ describe('displayCols 列宽估算', () => {
it('中英混排按叠加列宽计', () => { it('中英混排按叠加列宽计', () => {
expect(displayCols('a中b')).toBe(4) expect(displayCols('a中b')).toBe(4)
}) })
it('传入 cap 时达到上限提前返回', () => {
expect(displayCols('a'.repeat(9999), 5000)).toBe(5000)
})
it('未达 cap 时返回真实列宽', () => {
expect(displayCols('abc', 5000)).toBe(3)
})
it('全角字符跨过 cap 时返回实际累计列宽', () => {
// 2499 个半角后接全角:2499 + 2 = 2501 ≥ 2500
expect(displayCols('a'.repeat(2499) + '中', 2500)).toBe(2501)
})
}) })
+3 -1
View File
@@ -102,11 +102,13 @@ function isWideCodePoint(cp: number): boolean {
/** /**
* 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。 * 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。 * 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
* cap 为列宽累计上限:达到即提前返回,供调用方跳过超长行的全量逐字符遍历。
*/ */
export function displayCols(text: string): number { export function displayCols(text: string, cap?: number): number {
let n = 0 let n = 0
for (const ch of text) { for (const ch of text) {
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1 n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
if (cap !== undefined && n >= cap) return n
} }
return n return n
} }