diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7fa6827..3a6d517 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) const [paneR, setPaneR] = useState(null) - // 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取) - const [options, setOptions] = useState(() => loadPrefs().options) + // 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次) + const [initialPrefs] = useState(() => loadPrefs()) + // 比较选项与视图开关:初始值从持久化偏好恢复 + const [options, setOptions] = useState(initialPrefs.options) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) - const [onlyDiff, setOnlyDiff] = useState(() => loadPrefs().onlyDiff) + const [onlyDiff, setOnlyDiff] = useState(initialPrefs.onlyDiff) const [activeRowId, setActiveRowId] = useState(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([]) const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null) const [pasteOpen, setPasteOpen] = useState(false) const pasteWrapRef = useRef(null) @@ -147,22 +162,43 @@ export default function App(): ReactElement { // 点击外部 / 按 Esc 关闭粘贴下拉 useDismiss(pasteWrapRef, closePaste) - // toast 定时器:新提示先清掉旧定时器,避免前一条把后一条提前清空 - const toastTimer = useRef(null) + // toast 队列:最多同时展示 3 条,各自独立计时互不清除 + // (此前单条覆盖式会互顶:二进制乱码预警被行数预警立即顶掉,用户看不到前一条) + const toastSeq = useRef(0) + const toastTimers = useRef(new Map()) 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(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 { )} - @@ -554,20 +615,24 @@ export default function App(): ReactElement { onConfirm={(t) => confirmPaste(pasteSide, t)} /> )} - {toast && ( -
- {toast.text} - {toast.action && ( - - )} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
+ {t.text} + {t.action && ( + + )} +
+ ))}
)}
diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 0284167..987ac58 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -147,15 +147,60 @@ describe('App - 差异导航与清空', () => { expect(screen.getByText('2 / 2')).toBeInTheDocument() }) - it('点击清空恢复空态', async () => { + it('点击清空恢复空态(二次确认后执行)', async () => { render() 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() + 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() + 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() + fireEvent.click(screen.getByText('清空')) + expect(screen.queryByText(/再次点击/)).not.toBeInTheDocument() + expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument() + }) +}) + describe('App - 空态打开按钮', () => { it('点击空面板的打开文件按钮加载左侧文件', async () => { render() @@ -257,6 +302,75 @@ describe('App - 提示与容错', () => { expect(screen.queryByText(/仅支持文本文件:b.png/)).not.toBeInTheDocument() }) + it('两条提示同时可见(队列化不互顶),各自独立消失', () => { + vi.useFakeTimers() + render() + 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() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument() + expect(screen.getByText(/行数较多/)).toBeInTheDocument() + }) + + it('提示超过 3 条时最旧的一条被顶掉', () => { + vi.useFakeTimers() + render() + 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() + 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 }) diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index 9283072..de69303 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -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) diff --git a/src/renderer/src/hooks/useDiff.test.ts b/src/renderer/src/hooks/useDiff.test.ts index fdedbf8..917e427 100644 --- a/src/renderer/src/hooks/useDiff.test.ts +++ b/src/renderer/src/hooks/useDiff.test.ts @@ -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() diff --git a/src/renderer/src/hooks/useDiff.ts b/src/renderer/src/hooks/useDiff.ts index 90e6b83..bb365c0 100644 --- a/src/renderer/src/hooks/useDiff.ts +++ b/src/renderer/src/hooks/useDiff.ts @@ -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 diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index d0effd3..f50aefe 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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); } }