fix: 字符级大输入后台化、toast 队列化与清空二次确认

This commit is contained in:
2026-08-18 12:33:27 +08:00
parent 77dff98425
commit 3e3f9b173e
6 changed files with 301 additions and 39 deletions
+90 -25
View File
@@ -75,6 +75,19 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
/** toast 队列元素 */
interface ToastItem {
id: number
text: string
action?: { label: string; onClick: () => void }
}
/** toast 队列上限:超出时顶掉最旧的一条 */
const TOAST_MAX = 3
/** 清空二次确认窗口时长:第一次点击后在此时间内再点确认,超时自动复位 */
const CLEAR_CONFIRM_MS = 3000
interface PaneState {
meta: PaneMeta
path: string
@@ -132,14 +145,16 @@ function EmptyPane({
export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null)
// 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取
const [options, setOptions] = useState<DiffOptions>(() => loadPrefs().options)
// 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次
const [initialPrefs] = useState(() => loadPrefs())
// 比较选项与视图开关:初始值从持久化偏好恢复
const [options, setOptions] = useState<DiffOptions>(initialPrefs.options)
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState<boolean>(() => loadPrefs().onlyDiff)
const [onlyDiff, setOnlyDiff] = useState<boolean>(initialPrefs.onlyDiff)
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
const [toast, setToast] = useState<{ text: string; action?: { label: string; onClick: () => void } } | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null)
const [pasteOpen, setPasteOpen] = useState(false)
const pasteWrapRef = useRef<HTMLDivElement>(null)
@@ -147,22 +162,43 @@ export default function App(): ReactElement {
// 点击外部 / 按 Esc 关闭粘贴下拉
useDismiss(pasteWrapRef, closePaste)
// toast 定时器:新提示先清掉旧定时器,避免前一条把后一条提前清空
const toastTimer = useRef<number | null>(null)
// toast 队列:最多同时展示 3 条,各自独立计时互不清除
// (此前单条覆盖式会互顶:二进制乱码预警被行数预警立即顶掉,用户看不到前一条)
const toastSeq = useRef(0)
const toastTimers = useRef(new Map<number, number>())
const showToast = useCallback(
(text: string, action?: { label: string; onClick: () => void }) => {
setToast({ text, action })
if (toastTimer.current !== null) window.clearTimeout(toastTimer.current)
toastTimer.current = window.setTimeout(() => setToast(null), 3600)
const id = ++toastSeq.current
setToasts((prev) => {
const next = [...prev, { id, text, action }]
// 超出上限时顶掉最旧的一条(其定时器到期后空过滤,无副作用)
return next.length > TOAST_MAX ? next.slice(next.length - TOAST_MAX) : next
})
const timer = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
toastTimers.current.delete(id)
}, 3600)
toastTimers.current.set(id, timer)
},
[]
)
// 卸载时清理定时器,避免泄漏
const dismissToast = useCallback((id: number): void => {
const timer = toastTimers.current.get(id)
if (timer !== undefined) {
window.clearTimeout(timer)
toastTimers.current.delete(id)
}
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
// 卸载时清理全部定时器,避免泄漏
useEffect(() => {
const timers = toastTimers.current
return () => {
if (toastTimer.current !== null) window.clearTimeout(toastTimer.current)
timers.forEach((t) => window.clearTimeout(t))
timers.clear()
}
}, [])
@@ -304,6 +340,37 @@ export default function App(): ReactElement {
setPaneR(paneL)
}, [paneL, paneR])
// 清空二次确认:两侧有内容时第一次点击仅提示,确认窗口内再点才真正清空
// (文件可重新打开,粘贴输入的手动文本无法恢复,需防误触)
const clearArmedRef = useRef(false)
const clearTimer = useRef<number | null>(null)
const handleClear = useCallback((): void => {
// 空态无内容,无需确认成本
if (paneL === null && paneR === null) return
if (clearArmedRef.current) {
if (clearTimer.current !== null) window.clearTimeout(clearTimer.current)
clearArmedRef.current = false
clearTimer.current = null
setPaneL(null)
setPaneR(null)
return
}
clearArmedRef.current = true
showToast('再次点击“清空”将移除两侧内容')
clearTimer.current = window.setTimeout(() => {
clearArmedRef.current = false
clearTimer.current = null
}, CLEAR_CONFIRM_MS)
}, [paneL, paneR, showToast])
// 卸载时清理确认窗口定时器
useEffect(() => {
return () => {
if (clearTimer.current !== null) window.clearTimeout(clearTimer.current)
}
}, [])
// diff 内容或视图选项变化时重置导航
useEffect(() => {
setNavIndex(0)
@@ -472,13 +539,7 @@ export default function App(): ReactElement {
</div>
)}
</div>
<button
className="btn ghost"
onClick={() => {
setPaneL(null)
setPaneR(null)
}}
>
<button className="btn ghost" onClick={handleClear}>
</button>
</header>
@@ -554,21 +615,25 @@ export default function App(): ReactElement {
onConfirm={(t) => confirmPaste(pasteSide, t)}
/>
)}
{toast && (
<div className="toast">
<span className="toast-text">{toast.text}</span>
{toast.action && (
{toasts.length > 0 && (
<div className="toast-wrap">
{toasts.map((t) => (
<div className="toast" key={t.id}>
<span className="toast-text">{t.text}</span>
{t.action && (
<button
className="toast-btn"
onClick={() => {
toast.action!.onClick()
setToast(null)
t.action!.onClick()
dismissToast(t.id)
}}
>
{toast.action.label}
{t.action.label}
</button>
)}
</div>
))}
</div>
)}
</div>
)
+115 -1
View File
@@ -147,15 +147,60 @@ describe('App - 差异导航与清空', () => {
expect(screen.getByText('2 / 2')).toBeInTheDocument()
})
it('点击清空恢复空态', async () => {
it('点击清空恢复空态(二次确认后执行)', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('清空'))
expect(await screen.findByText(/再次点击/)).toBeInTheDocument()
fireEvent.click(screen.getByText('清空'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
})
})
describe('App - 清空二次确认', () => {
it('有内容时第一次点击仅提示,不清空', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('清空'))
expect(await screen.findByText(/再次点击“清空”将移除两侧内容/)).toBeInTheDocument()
// 未清空:对比视图仍在
expect(screen.getByText('导出报告')).toBeInTheDocument()
})
it('确认窗口超时后自动复位(再点一次仍先提示)', async () => {
vi.useFakeTimers()
render(<App />)
await act(async () => {
fireEvent.click(screen.getByText('打开左侧'))
})
expect(screen.getByText('导出报告')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('清空'))
})
expect(screen.getAllByText(/再次点击/).length).toBeGreaterThan(0)
// 超过确认窗口 + toast 自身寿命:复位且提示消失
act(() => {
vi.advanceTimersByTime(4000)
})
expect(screen.queryByText(/再次点击/)).not.toBeInTheDocument()
// 复位后再点击仍是“先提示”而非直接清空
await act(async () => {
fireEvent.click(screen.getByText('清空'))
})
expect(screen.getAllByText(/再次点击/).length).toBeGreaterThan(0)
expect(screen.getByText('导出报告')).toBeInTheDocument()
})
it('空态时点击清空不弹确认(无内容无确认成本)', () => {
render(<App />)
fireEvent.click(screen.getByText('清空'))
expect(screen.queryByText(/再次点击/)).not.toBeInTheDocument()
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
})
})
describe('App - 空态打开按钮', () => {
it('点击空面板的打开文件按钮加载左侧文件', async () => {
render(<App />)
@@ -257,6 +302,75 @@ describe('App - 提示与容错', () => {
expect(screen.queryByText(/仅支持文本文件:b.png/)).not.toBeInTheDocument()
})
it('两条提示同时可见(队列化不互顶),各自独立消失', () => {
vi.useFakeTimers()
render(<App />)
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
fireEvent.drop(pane, {
dataTransfer: { files: [new File(['x'], 'a.png', { type: 'image/png' })] }
})
fireEvent.drop(pane, {
dataTransfer: { files: [new File(['x'], 'b.png', { type: 'image/png' })] }
})
// 两条都在队列中同时展示
expect(screen.getByText(/仅支持文本文件:a.png/)).toBeInTheDocument()
expect(screen.getByText(/仅支持文本文件:b.png/)).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(3700)
})
expect(screen.queryByText(/仅支持文本文件:a.png/)).not.toBeInTheDocument()
expect(screen.queryByText(/仅支持文本文件:b.png/)).not.toBeInTheDocument()
})
it('二进制乱码预警与行数预警同时可见(原单条覆盖式会互顶)', async () => {
window.api = mockApi({
openFile: async () => ({
path: '/tmp/bin.log',
name: 'bin.log',
text: Array.from({ length: 50001 }, () => 'x').join('\n'),
encoding: 'GBK',
binary: true
})
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument()
expect(screen.getByText(/行数较多/)).toBeInTheDocument()
})
it('提示超过 3 条时最旧的一条被顶掉', () => {
vi.useFakeTimers()
render(<App />)
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
const names = ['a.png', 'b.png', 'c.png', 'd.png']
for (const n of names) {
fireEvent.drop(pane, {
dataTransfer: { files: [new File(['x'], n, { type: 'image/png' })] }
})
}
expect(screen.queryByText(/仅支持文本文件:a.png/)).not.toBeInTheDocument()
expect(screen.getByText(/仅支持文本文件:b.png/)).toBeInTheDocument()
expect(screen.getByText(/仅支持文本文件:c.png/)).toBeInTheDocument()
expect(screen.getByText(/仅支持文本文件:d.png/)).toBeInTheDocument()
})
it('点击 toast 动作按钮后该条立即消失', async () => {
const showInFolder = vi.fn(async () => true)
window.api = mockApi({
saveReport: async () => ({ ok: true, path: 'C:/docs/DiffLens-report.html' }),
showInFolder
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('HTML 报告'))
expect(await screen.findByText(/报告已保存/)).toBeInTheDocument()
fireEvent.click(screen.getByText('打开文件夹'))
expect(showInFolder).toHaveBeenCalled()
expect(screen.queryByText(/报告已保存/)).not.toBeInTheDocument()
})
it('文件超过 10MB 时被拦截并保持空态', async () => {
window.api = mockApi({
openFile: async () => ({ error: 'too-large', name: 'big.log', size: 11 * 1024 * 1024 })
+8
View File
@@ -291,6 +291,14 @@ function computeLineDiff(
*/
export const CHAR_DIFF_MAX_CHARS = 200000
/**
* 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。
* 按 CHAR_DIFF_MAX_CHARS / 4 取值——降级上限之内仍可能出现编辑距离极大、
* O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行),
* 行数维度的重输入判定防不住,需以字符量兜底。
*/
export const CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS / 4
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
function isWsChar(ch: string): boolean {
return /\s/.test(ch)
+56
View File
@@ -46,6 +46,8 @@ function heavyText(lines: number): string {
// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑,
// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定)
const NO_OPTIONS: DiffOptions = {}
const CHAR_ON: DiffOptions = { charMode: true }
const CHAR_OFF: DiffOptions = { charMode: false }
beforeEach(() => {
vi.useFakeTimers()
@@ -71,6 +73,60 @@ describe('useDiff - 快路径', () => {
})
})
describe('useDiff - 字符级大字符量重路径', () => {
it('少行大字符量输入(charMode 开启)进入重路径后台计算', () => {
const factory = makeFactory()
// 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS
const big = 'a'.repeat(30000) + '\n' + 'b'.repeat(30000)
const { result } = renderHook(() => useDiff(big, big, CHAR_ON, factory))
expect(result.current.computing).toBe(true)
expect(result.current.diff).toBeNull()
// 去抖期内尚未创建 worker
expect(factory.workers).toHaveLength(0)
act(() => {
vi.advanceTimersByTime(250)
})
expect(factory.workers).toHaveLength(1)
const w = factory.workers[0]
const res = computeDiff(big, big, CHAR_ON)
act(() => {
w.respond(w.posted[0].jobId, res)
})
expect(result.current.diff).toEqual(res)
expect(result.current.computing).toBe(false)
})
it('charMode 开启但字符量未超阈值仍走快路径', () => {
const factory = vi.fn(makeFactory())
const { result } = renderHook(() => useDiff('ab\ncd', 'ab\ncd', CHAR_ON, factory))
expect(result.current.computing).toBe(false)
expect(result.current.diff).not.toBeNull()
expect(result.current.diff!.summary.changedLines).toBe(0)
expect(factory).not.toHaveBeenCalled()
})
it('charMode 关闭时大字符量输入不触发重路径(行数维度判定不变)', () => {
const factory = vi.fn(makeFactory())
const big = 'a'.repeat(60000)
const { result } = renderHook(() => useDiff(big, big, CHAR_OFF, factory))
expect(result.current.computing).toBe(false)
expect(result.current.diff).not.toBeNull()
expect(factory).not.toHaveBeenCalled()
})
it('恰好阈值不触发重路径,超过阈值触发', () => {
const factory = makeFactory()
// 两侧各 25000 字符 = 恰好 50000CHAR_HEAVY_INPUT_CHARS):不触发
const at = 'a'.repeat(25000)
const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory))
expect(hook1.result.current.computing).toBe(false)
// 25001 + 25000 = 50001:触发
const over = 'a'.repeat(25001)
const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory))
expect(hook2.result.current.computing).toBe(true)
})
})
describe('useDiff - worker 后台计算', () => {
it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => {
const factory = makeFactory()
+12 -3
View File
@@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { computeDiff, type DiffOptions, type DiffResult } from '../diff/diffEngine'
import {
computeDiff,
CHAR_HEAVY_INPUT_CHARS,
type DiffOptions,
type DiffResult
} from '../diff/diffEngine'
import { createDiffWorker, type DiffWorkerFactory } from '../diff/createDiffWorker'
import type { DiffWorkerResponse } from '../diff/diffWorker'
import { countLines } from '../diff/textUtils'
@@ -31,8 +36,12 @@ export function useDiff(
workerFactory?: DiffWorkerFactory
): DiffState {
const heavy = useMemo(
() => countLines(left) + countLines(right) > FAST_PATH_MAX_LINES,
[left, right]
() =>
countLines(left) + countLines(right) > FAST_PATH_MAX_LINES ||
// 字符级对比的耗时由字符量×编辑距离决定:少行大字符量输入(压缩 JSON / base64 单行)
// 行数维度防不住,需按字符量兜底走 worker 后台计算
(options.charMode === true && left.length + right.length > CHAR_HEAVY_INPUT_CHARS),
[left, right, options]
)
const factory = workerFactory ?? createDiffWorker
+13 -3
View File
@@ -599,12 +599,21 @@ button:disabled {
bottom: calc(100% + 6px);
}
.toast {
/* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 46px;
transform: translateX(-50%);
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 72vw;
pointer-events: none;
}
.toast {
max-width: 72vw;
padding: 9px 16px;
border-radius: 10px;
@@ -616,16 +625,17 @@ button:disabled {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: auto;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 8px);
transform: translateY(8px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
transform: translateY(0);
}
}