fix: v0.3.2 报告对齐与性能收尾 - 纯文本报告行号右对齐、超长行列宽封顶防卡顿、对话框与拖拽扩展名清单对齐、右键菜单打开时导航快捷键守卫、清理导出死属性
This commit is contained in:
+3
-2
@@ -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_EXTENSIONS(src/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: ['*'] }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 - 折叠提示行', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -20,9 +20,11 @@ export interface ReportContext {
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
/** 格式化为固定宽行号(右对齐) */
|
||||
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 ?? ''}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user