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
+3 -2
View File
@@ -94,8 +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'] },
// 兜底入口:拖拽白名单比对话框筛选项更全(svelte/tsv/lock 等),补“所有文件”消除两种入口的不一致
// 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步
{ 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: ['*'] }
]
})
+3 -4
View File
@@ -266,17 +266,17 @@ export default function App(): ReactElement {
[changedRows, navIndex]
)
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗或右键菜单打开时不抢占
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'F7') return
if (pasteSide !== null) return
if (pasteSide !== null || menu !== null) return
e.preventDefault()
go(e.shiftKey ? -1 : 1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [go, pasteSide])
}, [go, pasteSide, menu])
// 行级右键菜单
const onRowContext = useCallback(
@@ -438,7 +438,6 @@ export default function App(): ReactElement {
navCount={changedRows.length}
onNav={go}
onExport={(fmt) => void exportReport(fmt)}
canExport={anyPane}
onlyDiff={onlyDiff}
onOnlyDiffChange={setOnlyDiff}
onSwap={swapPanes}
+11
View File
@@ -491,6 +491,17 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => {
// 导航未被触发,计数保持 1 / 2
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 - 交换左右侧', () => {
@@ -254,6 +254,32 @@ describe('DiffView - 虚拟滚动', () => {
expect(columns[0].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 - 折叠提示行', () => {
+3 -1
View File
@@ -213,13 +213,15 @@ export default function DiffView({
if (w > 0) setCharW(w / 10)
}, [])
/** 列宽计算封顶:超长行(base64/压缩产物可达数 MB 单行)达到上限即停,避免逐字符全量遍历;与词级防护阈值同源 */
const MAX_COLS_CAP = 5000
const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => {
let max = 40
for (const it of rows) {
if (isFold(it)) continue
const t = pick(it).text
if (t) {
const c = displayCols(t)
const c = displayCols(t, MAX_COLS_CAP)
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 }
function setup(extra?: { canExport?: boolean }) {
function setup() {
const onOptionsChange = vi.fn()
const onNav = vi.fn()
const onExport = vi.fn()
@@ -20,7 +20,6 @@ function setup(extra?: { canExport?: boolean }) {
navCount={3}
onNav={onNav}
onExport={onExport}
canExport={extra?.canExport ?? true}
onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap}
+1 -7
View File
@@ -17,7 +17,6 @@ interface ToolbarProps {
navCount: number
onNav: (dir: 1 | -1) => void
onExport: (fmt: ReportFormat) => void
canExport: boolean
/** 仅看差异视图开关 */
onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void
@@ -33,7 +32,6 @@ export default function Toolbar({
navCount,
onNav,
onExport,
canExport,
onlyDiff,
onOnlyDiffChange,
onSwap
@@ -115,11 +113,7 @@ export default function Toolbar({
</div>
<div className="tool-group export-wrap" ref={exportWrapRef}>
<button
className="btn primary"
disabled={!canExport}
onClick={() => setExportOpen((o) => !o)}
>
<button className="btn primary" onClick={() => setExportOpen((o) => !o)}>
</button>
{exportOpen && (
+16
View File
@@ -29,6 +29,22 @@ describe('buildTxtReport', () => {
expect(txt).toContain('DiffLens')
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', () => {
+17 -6
View File
@@ -20,9 +20,11 @@ export interface ReportContext {
const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 格式化为固定宽行号(右对齐) */
function padNo(n: number | null): string {
return n === null ? '' : String(n)
/** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */
function padNo(n: number | null, width?: number): string {
if (n === null) return ''
const s = String(n)
return width !== undefined ? s.padStart(width) : s
}
/** 单元格文本(词级高亮段在 HTML 里渲染为 span) */
@@ -140,16 +142,25 @@ export function buildTxtReport(
const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======')
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) {
const tag = typeTag(row.rowKind)
if (row.rowKind === 'modified') {
// 修改行:左/右并置
const l = padNo(row.left.lineNo) || ' '
const r = padNo(row.right.lineNo) || ' '
const l = padNo(row.left.lineNo, width)
const r = padNo(row.right.lineNo, width)
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
} else {
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
lines.push(`${tag} ${no} | ${text ?? ''}`)
+13
View File
@@ -68,4 +68,17 @@ describe('displayCols 列宽估算', () => {
it('中英混排按叠加列宽计', () => {
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 列。
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
* cap 为列宽累计上限:达到即提前返回,供调用方跳过超长行的全量逐字符遍历。
*/
export function displayCols(text: string): number {
export function displayCols(text: string, cap?: number): number {
let n = 0
for (const ch of text) {
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
if (cap !== undefined && n >= cap) return n
}
return n
}