fix: v0.3.1 稳健性与测试补强 - 超长单行词级diff防护、打开对话框补所有文件筛选、虚拟滚动minWidth测试覆盖、滚动同步去锁化重构
This commit is contained in:
+3
-1
@@ -94,7 +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'] }
|
||||
{ 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 等),补“所有文件”消除两种入口的不一致
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
|
||||
@@ -219,6 +219,41 @@ describe('DiffView - 虚拟滚动', () => {
|
||||
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
|
||||
expect(columns[0].style.height).toBe(`${100 * ROW_HEIGHT}px`)
|
||||
})
|
||||
|
||||
it('探针测得字符宽后按最大列宽设置 minWidth', () => {
|
||||
// 探针 10 字符 mock 宽度 70px → 每字符 7px;左 50 列 / 右 60 列(均超过 colsOf 的 40 列下限)
|
||||
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 wideRow: DiffRow = {
|
||||
id: 'w1',
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: { lineNo: 1, text: 'a'.repeat(50), segs: null },
|
||||
right: { lineNo: 1, text: '中'.repeat(30), segs: null }
|
||||
}
|
||||
const { container } = renderDiff([wideRow])
|
||||
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
|
||||
// 左:50 半角列 × 7 + 86 固定开销;右:30 全角 = 60 列 × 7 + 86
|
||||
expect(columns[0].style.minWidth).toBe('436px')
|
||||
expect(columns[1].style.minWidth).toBe('506px')
|
||||
rectSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('探针未测得宽度时不设置 minWidth(退回 CSS max-content)', () => {
|
||||
const { container } = renderDiff(baseRows)
|
||||
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
|
||||
expect(columns[0].style.minWidth).toBe('')
|
||||
expect(columns[1].style.minWidth).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffView - 折叠提示行', () => {
|
||||
|
||||
@@ -204,7 +204,6 @@ export default function DiffView({
|
||||
}: DiffViewProps): ReactElement {
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(null)
|
||||
const syncing = useRef(false)
|
||||
|
||||
// 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度
|
||||
const probeRef = useRef<HTMLSpanElement>(null)
|
||||
@@ -232,20 +231,16 @@ export default function DiffView({
|
||||
const leftMinW = charW > 0 ? leftCols * charW + CONTENT_WIDTH_PAD : undefined
|
||||
const rightMinW = charW > 0 ? rightCols * charW + CONTENT_WIDTH_PAD : undefined
|
||||
|
||||
// 双向滚动同步(差值判断,无锁):一侧滚动后仅在两侧位置差超过 1px 时回写对侧;
|
||||
// 回写触发的对侧 scroll 事件因差值归零自然终止,不依赖 rAF 时序假设,快速滚动不丢帧
|
||||
const makeScrollSync =
|
||||
(target: 'left' | 'right') =>
|
||||
(): void => {
|
||||
if (syncing.current) return
|
||||
syncing.current = true
|
||||
const src = target === 'left' ? leftRef.current : rightRef.current
|
||||
const dst = target === 'left' ? rightRef.current : leftRef.current
|
||||
if (src && dst) {
|
||||
dst.scrollTop = src.scrollTop
|
||||
dst.scrollLeft = src.scrollLeft
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
syncing.current = false
|
||||
})
|
||||
if (!src || !dst) return
|
||||
if (Math.abs(dst.scrollTop - src.scrollTop) > 1) dst.scrollTop = src.scrollTop
|
||||
if (Math.abs(dst.scrollLeft - src.scrollLeft) > 1) dst.scrollLeft = src.scrollLeft
|
||||
}
|
||||
|
||||
// 差异导航定位:目标行换算为受控 scrollTop(虚拟化下行可能未渲染,scrollIntoView 不可用)
|
||||
|
||||
@@ -148,6 +148,30 @@ describe('computeDiff - 忽略空行', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 超长行词级防护', () => {
|
||||
it('任一侧超过阈值时跳过词级高亮,行仍为 modified', () => {
|
||||
const { rows, summary } = computeDiff('a'.repeat(5001), 'b'.repeat(5001))
|
||||
expect(summary.modified).toBe(1)
|
||||
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.segs).toBeNull()
|
||||
expect(m.right.segs).toBeNull()
|
||||
})
|
||||
|
||||
it('恰好阈值长度仍产生词级高亮', () => {
|
||||
const { rows } = computeDiff('a'.repeat(5000), 'b'.repeat(5000))
|
||||
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.segs).not.toBeNull()
|
||||
expect(m.right.segs).not.toBeNull()
|
||||
})
|
||||
|
||||
it('一侧短一侧超长同样跳过', () => {
|
||||
const { rows } = computeDiff('short', 'z'.repeat(6000))
|
||||
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.segs).toBeNull()
|
||||
expect(m.right.segs).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 行切分细节', () => {
|
||||
it('结尾换行不产生多余空行', () => {
|
||||
const { rows, summary } = computeDiff('a\n', 'a')
|
||||
|
||||
@@ -58,6 +58,12 @@ interface NumberedLine {
|
||||
lineNo: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 词级高亮的单行长度上限:任一侧超长即跳过词级 diff,整行按行级背景展示。
|
||||
* 压缩产物 / base64 / 无换行日志的单行可达数 MB,词法 diff 会长时间阻塞渲染进程。
|
||||
*/
|
||||
const WORD_DIFF_MAX_LEN = 5000
|
||||
|
||||
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
|
||||
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
|
||||
const parts = diffWordsWithSpace(leftText, rightText)
|
||||
@@ -146,7 +152,12 @@ export function computeDiff(
|
||||
const l = queuedRemoved[i]
|
||||
const r = addedRows[i]
|
||||
if (l && r) {
|
||||
const { left: lSegs, right: rSegs } = wordSegments(l.text, r.text)
|
||||
// 任一侧超长则跳过词级高亮(segs 置空),行仍按 modified 展示行级背景
|
||||
const skipWordDiff =
|
||||
l.text.length > WORD_DIFF_MAX_LEN || r.text.length > WORD_DIFF_MAX_LEN
|
||||
const { left: lSegs, right: rSegs } = skipWordDiff
|
||||
? { left: null, right: null }
|
||||
: wordSegments(l.text, r.text)
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'modified',
|
||||
|
||||
@@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean {
|
||||
return TEXT_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护) */
|
||||
/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) */
|
||||
export const HEAVY_LINES = 50000
|
||||
|
||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||
|
||||
Reference in New Issue
Block a user