fix: v0.2.6 主进程异步收尾与边角防护 - 保存报告异步写盘、拖拽解码handler异步化、空白粘贴拦截、超多行卡顿预警、清理mac无效签名配置、文档与测试同步
This commit is contained in:
+3
-2
@@ -115,7 +115,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
|
||||
})
|
||||
|
||||
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
|
||||
ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => {
|
||||
ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
|
||||
return decodeText(Buffer.from(buffer))
|
||||
})
|
||||
|
||||
@@ -131,7 +131,8 @@ ipcMain.handle('file:save-report', async (_event, content: string, defaultName:
|
||||
]
|
||||
})
|
||||
if (result.canceled || !result.filePath) return { ok: false, path: null }
|
||||
fs.writeFileSync(result.filePath, content, 'utf-8')
|
||||
// 异步写盘,避免大报告(MB 级 HTML)同步写入时阻塞主进程事件循环
|
||||
await fs.promises.writeFile(result.filePath, content, 'utf-8')
|
||||
return { ok: true, path: result.filePath }
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import Toolbar from './components/Toolbar'
|
||||
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
||||
import TextInputModal from './components/TextInputModal'
|
||||
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
|
||||
import { isTextFile } from './diff/textUtils'
|
||||
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
|
||||
import { useDismiss } from './hooks/useDismiss'
|
||||
import type { PaneMeta } from './components/DiffView'
|
||||
import type { SideCell } from './diff/diffEngine'
|
||||
@@ -119,6 +119,15 @@ export default function App(): ReactElement {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 超多行预警:行数超过阈值时提示滚动可能卡顿(不阻断加载)
|
||||
const warnHeavyLines = useCallback(
|
||||
(text: string): void => {
|
||||
const lines = countLines(text)
|
||||
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),滚动可能出现卡顿`)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const openPane = useCallback(
|
||||
async (side: 'left' | 'right') => {
|
||||
let data: Awaited<ReturnType<typeof window.api.openFile>>
|
||||
@@ -138,6 +147,7 @@ export default function App(): ReactElement {
|
||||
return
|
||||
}
|
||||
if (data.binary) showToast(`疑似二进制文件,内容可能乱码:${data.name}`)
|
||||
warnHeavyLines(data.text)
|
||||
const pane: PaneState = {
|
||||
meta: { name: data.name, encoding: data.encoding },
|
||||
path: data.path,
|
||||
@@ -146,7 +156,7 @@ export default function App(): ReactElement {
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
},
|
||||
[showToast]
|
||||
[showToast, warnHeavyLines]
|
||||
)
|
||||
|
||||
const dropPane = useCallback(
|
||||
@@ -181,11 +191,12 @@ export default function App(): ReactElement {
|
||||
text = fallback
|
||||
}
|
||||
if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`)
|
||||
warnHeavyLines(text)
|
||||
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
},
|
||||
[showToast]
|
||||
[showToast, warnHeavyLines]
|
||||
)
|
||||
|
||||
const diff: DiffResult = useMemo(() => {
|
||||
@@ -276,17 +287,23 @@ export default function App(): ReactElement {
|
||||
|
||||
const confirmPaste = useCallback(
|
||||
(side: 'left' | 'right', text: string) => {
|
||||
// 空白内容拦截:进入对比只会得到空面板与"完全一致"的误导性结果
|
||||
if (text.trim() === '') {
|
||||
showToast('内容为空,请先输入或粘贴文本')
|
||||
return
|
||||
}
|
||||
// 与文件入口的 10MB 字节上限保持对称:超限拒绝且不关闭弹窗,保留内容供编辑
|
||||
if (new TextEncoder().encode(text).length > MAX_FILE_SIZE) {
|
||||
showToast('粘贴内容超过 10MB,暂不支持对比')
|
||||
return
|
||||
}
|
||||
warnHeavyLines(text)
|
||||
const pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text }
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
setPasteSide(null)
|
||||
},
|
||||
[showToast]
|
||||
[showToast, warnHeavyLines]
|
||||
)
|
||||
|
||||
const leftMeta = paneL?.meta ?? null
|
||||
|
||||
@@ -415,4 +415,17 @@ describe('App - 手动粘贴文本', () => {
|
||||
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
||||
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('粘贴空白内容被拒绝且弹窗保持打开', () => {
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('粘贴文本'))
|
||||
fireEvent.click(screen.getByText('粘贴到左侧'))
|
||||
fireEvent.change(screen.getByPlaceholderText(/粘贴或输入/), {
|
||||
target: { value: ' \n ' }
|
||||
})
|
||||
fireEvent.click(screen.getByText('开始对比'))
|
||||
expect(screen.getByText(/内容为空/)).toBeInTheDocument()
|
||||
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
||||
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -67,6 +67,32 @@ describe('computeDiff - 词级修改', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 多块修改与不配对增删', () => {
|
||||
it('多个独立修改块各自配对为 modified', () => {
|
||||
const { rows, summary } = computeDiff('a\nX\nb\nY\nc', 'a\nZ\nb\nW\nc')
|
||||
expect(summary.modified).toBe(2)
|
||||
const mods = rows.filter((r) => r.rowKind === 'modified')
|
||||
expect(mods[0].left.text).toBe('X')
|
||||
expect(mods[0].right.text).toBe('Z')
|
||||
expect(mods[1].left.text).toBe('Y')
|
||||
expect(mods[1].right.text).toBe('W')
|
||||
})
|
||||
|
||||
it('删除多于新增时多余删除行降级为 removed', () => {
|
||||
const { summary } = computeDiff('l1\nl2\nl3\nl4', 'l1\nl9\nl4')
|
||||
// l2 与 l9 配对为修改,l3 无新增可配对 → removed
|
||||
expect(summary.modified).toBe(1)
|
||||
expect(summary.deleted).toBe(1)
|
||||
})
|
||||
|
||||
it('新增多于删除时多余新增行保持 added', () => {
|
||||
const { rows, summary } = computeDiff('a\nc', 'a\nb1\nb2\nc')
|
||||
expect(summary.inserted).toBe(2)
|
||||
const added = rows.filter((r) => r.rowKind === 'added')
|
||||
expect(added.map((r) => r.right.text)).toEqual(['b1', 'b2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 忽略选项', () => {
|
||||
it('忽略大小写', () => {
|
||||
const { summary } = computeDiff('Alpha', 'alpha', { ignoreCase: true })
|
||||
@@ -78,6 +104,11 @@ describe('computeDiff - 忽略选项', () => {
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('空白与大小写忽略选项组合生效', () => {
|
||||
const { summary } = computeDiff(' Alpha ', 'alpha', { trimWhitespace: true, ignoreCase: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('未开启忽略时同一纯净差异仍被识别', () => {
|
||||
const { summary } = computeDiff(' value ', 'value')
|
||||
expect(summary.changedLines).toBeGreaterThan(0)
|
||||
|
||||
@@ -68,8 +68,8 @@ function wordSegments(leftText: string, rightText: string): { left: Seg[]; right
|
||||
return { left, right }
|
||||
}
|
||||
|
||||
/** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行 */
|
||||
function splitLines(text: string): string[] {
|
||||
/** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行(导出供行数统计复用,保持切分规则一致) */
|
||||
export function splitLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const lines = text.split(/\r?\n/)
|
||||
if (lines[lines.length - 1] === '') lines.pop()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { TEXT_EXTENSIONS, isTextFile } from './textUtils'
|
||||
import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines } from './textUtils'
|
||||
|
||||
describe('textUtils', () => {
|
||||
it('常见文本扩展名放行', () => {
|
||||
@@ -30,4 +30,26 @@ describe('textUtils', () => {
|
||||
expect(TEXT_EXTENSIONS.length).toBeGreaterThan(0)
|
||||
expect(TEXT_EXTENSIONS).toContain('txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countLines / HEAVY_LINES', () => {
|
||||
it('空文本为 0 行', () => {
|
||||
expect(countLines('')).toBe(0)
|
||||
})
|
||||
|
||||
it('无结尾换行按实际行数', () => {
|
||||
expect(countLines('a\nb\nc')).toBe(3)
|
||||
})
|
||||
|
||||
it('结尾换行不产生额外空行(与 diff 引擎切分一致)', () => {
|
||||
expect(countLines('a\nb\n')).toBe(2)
|
||||
})
|
||||
|
||||
it('CRLF 换行同样切分', () => {
|
||||
expect(countLines('a\r\nb')).toBe(2)
|
||||
})
|
||||
|
||||
it('超多行预警阈值为 50000', () => {
|
||||
expect(HEAVY_LINES).toBe(50000)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { splitLines } from './diffEngine'
|
||||
|
||||
/** 允许打开/拖拽的文本类扩展名(小写,不含点) */
|
||||
export const TEXT_EXTENSIONS: string[] = [
|
||||
'txt',
|
||||
@@ -71,4 +73,12 @@ export function isTextFile(name: string): boolean {
|
||||
if (dot < 0) return true
|
||||
const ext = name.slice(dot + 1).toLowerCase()
|
||||
return TEXT_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/** 超多行预警阈值:超过该行数时提示滚动可能卡顿(虚拟滚动落地前的过渡防护) */
|
||||
export const HEAVY_LINES = 50000
|
||||
|
||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||
export function countLines(text: string): number {
|
||||
return splitLines(text).length
|
||||
}
|
||||
Reference in New Issue
Block a user