fix: 字符级大输入后台化、toast 队列化与清空二次确认
This commit is contained in:
+97
-32
@@ -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,20 +615,24 @@ export default function App(): ReactElement {
|
||||
onConfirm={(t) => confirmPaste(pasteSide, t)}
|
||||
/>
|
||||
)}
|
||||
{toast && (
|
||||
<div className="toast">
|
||||
<span className="toast-text">{toast.text}</span>
|
||||
{toast.action && (
|
||||
<button
|
||||
className="toast-btn"
|
||||
onClick={() => {
|
||||
toast.action!.onClick()
|
||||
setToast(null)
|
||||
}}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
{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={() => {
|
||||
t.action!.onClick()
|
||||
dismissToast(t.id)
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 字符 = 恰好 50000(CHAR_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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user