fix: 字符级大输入后台化、toast 队列化与清空二次确认
This commit is contained in:
+90
-25
@@ -75,6 +75,19 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
|
|||||||
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
|
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
|
||||||
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
|
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 {
|
interface PaneState {
|
||||||
meta: PaneMeta
|
meta: PaneMeta
|
||||||
path: string
|
path: string
|
||||||
@@ -132,14 +145,16 @@ function EmptyPane({
|
|||||||
export default function App(): ReactElement {
|
export default function App(): ReactElement {
|
||||||
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
||||||
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
||||||
// 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取)
|
// 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
|
||||||
const [options, setOptions] = useState<DiffOptions>(() => loadPrefs().options)
|
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 [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||||
const [navIndex, setNavIndex] = useState(0)
|
const [navIndex, setNavIndex] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
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 [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null)
|
||||||
const [pasteOpen, setPasteOpen] = useState(false)
|
const [pasteOpen, setPasteOpen] = useState(false)
|
||||||
const pasteWrapRef = useRef<HTMLDivElement>(null)
|
const pasteWrapRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -147,22 +162,43 @@ export default function App(): ReactElement {
|
|||||||
// 点击外部 / 按 Esc 关闭粘贴下拉
|
// 点击外部 / 按 Esc 关闭粘贴下拉
|
||||||
useDismiss(pasteWrapRef, closePaste)
|
useDismiss(pasteWrapRef, closePaste)
|
||||||
|
|
||||||
// toast 定时器:新提示先清掉旧定时器,避免前一条把后一条提前清空
|
// toast 队列:最多同时展示 3 条,各自独立计时互不清除
|
||||||
const toastTimer = useRef<number | null>(null)
|
// (此前单条覆盖式会互顶:二进制乱码预警被行数预警立即顶掉,用户看不到前一条)
|
||||||
|
const toastSeq = useRef(0)
|
||||||
|
const toastTimers = useRef(new Map<number, number>())
|
||||||
|
|
||||||
const showToast = useCallback(
|
const showToast = useCallback(
|
||||||
(text: string, action?: { label: string; onClick: () => void }) => {
|
(text: string, action?: { label: string; onClick: () => void }) => {
|
||||||
setToast({ text, action })
|
const id = ++toastSeq.current
|
||||||
if (toastTimer.current !== null) window.clearTimeout(toastTimer.current)
|
setToasts((prev) => {
|
||||||
toastTimer.current = window.setTimeout(() => setToast(null), 3600)
|
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(() => {
|
useEffect(() => {
|
||||||
|
const timers = toastTimers.current
|
||||||
return () => {
|
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)
|
setPaneR(paneL)
|
||||||
}, [paneL, paneR])
|
}, [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 内容或视图选项变化时重置导航
|
// diff 内容或视图选项变化时重置导航
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNavIndex(0)
|
setNavIndex(0)
|
||||||
@@ -472,13 +539,7 @@ export default function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button className="btn ghost" onClick={handleClear}>
|
||||||
className="btn ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setPaneL(null)
|
|
||||||
setPaneR(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -554,21 +615,25 @@ export default function App(): ReactElement {
|
|||||||
onConfirm={(t) => confirmPaste(pasteSide, t)}
|
onConfirm={(t) => confirmPaste(pasteSide, t)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{toast && (
|
{toasts.length > 0 && (
|
||||||
<div className="toast">
|
<div className="toast-wrap">
|
||||||
<span className="toast-text">{toast.text}</span>
|
{toasts.map((t) => (
|
||||||
{toast.action && (
|
<div className="toast" key={t.id}>
|
||||||
|
<span className="toast-text">{t.text}</span>
|
||||||
|
{t.action && (
|
||||||
<button
|
<button
|
||||||
className="toast-btn"
|
className="toast-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.action!.onClick()
|
t.action!.onClick()
|
||||||
setToast(null)
|
dismissToast(t.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{toast.action.label}
|
{t.action.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,15 +147,60 @@ describe('App - 差异导航与清空', () => {
|
|||||||
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击清空恢复空态', async () => {
|
it('点击清空恢复空态(二次确认后执行)', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
fireEvent.click(screen.getByText('打开左侧'))
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
await screen.findByText('导出报告')
|
await screen.findByText('导出报告')
|
||||||
fireEvent.click(screen.getByText('清空'))
|
fireEvent.click(screen.getByText('清空'))
|
||||||
|
expect(await screen.findByText(/再次点击/)).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('清空'))
|
||||||
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
|
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 - 空态打开按钮', () => {
|
describe('App - 空态打开按钮', () => {
|
||||||
it('点击空面板的打开文件按钮加载左侧文件', async () => {
|
it('点击空面板的打开文件按钮加载左侧文件', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
@@ -257,6 +302,75 @@ describe('App - 提示与容错', () => {
|
|||||||
expect(screen.queryByText(/仅支持文本文件:b.png/)).not.toBeInTheDocument()
|
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 () => {
|
it('文件超过 10MB 时被拦截并保持空态', async () => {
|
||||||
window.api = mockApi({
|
window.api = mockApi({
|
||||||
openFile: async () => ({ error: 'too-large', name: 'big.log', size: 11 * 1024 * 1024 })
|
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
|
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 语义一致(含换行、制表符、全角空格) */
|
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
|
||||||
function isWsChar(ch: string): boolean {
|
function isWsChar(ch: string): boolean {
|
||||||
return /\s/.test(ch)
|
return /\s/.test(ch)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ function heavyText(lines: number): string {
|
|||||||
// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑,
|
// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑,
|
||||||
// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定)
|
// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定)
|
||||||
const NO_OPTIONS: DiffOptions = {}
|
const NO_OPTIONS: DiffOptions = {}
|
||||||
|
const CHAR_ON: DiffOptions = { charMode: true }
|
||||||
|
const CHAR_OFF: DiffOptions = { charMode: false }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
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 后台计算', () => {
|
describe('useDiff - worker 后台计算', () => {
|
||||||
it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => {
|
it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => {
|
||||||
const factory = makeFactory()
|
const factory = makeFactory()
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 { createDiffWorker, type DiffWorkerFactory } from '../diff/createDiffWorker'
|
||||||
import type { DiffWorkerResponse } from '../diff/diffWorker'
|
import type { DiffWorkerResponse } from '../diff/diffWorker'
|
||||||
import { countLines } from '../diff/textUtils'
|
import { countLines } from '../diff/textUtils'
|
||||||
@@ -31,8 +36,12 @@ export function useDiff(
|
|||||||
workerFactory?: DiffWorkerFactory
|
workerFactory?: DiffWorkerFactory
|
||||||
): DiffState {
|
): DiffState {
|
||||||
const heavy = useMemo(
|
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
|
const factory = workerFactory ?? createDiffWorker
|
||||||
|
|
||||||
|
|||||||
@@ -599,12 +599,21 @@ button:disabled {
|
|||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + 6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
/* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */
|
||||||
|
.toast-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 46px;
|
bottom: 46px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 72vw;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
max-width: 72vw;
|
max-width: 72vw;
|
||||||
padding: 9px 16px;
|
padding: 9px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -616,16 +625,17 @@ button:disabled {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
pointer-events: auto;
|
||||||
animation: toast-in 0.2s ease;
|
animation: toast-in 0.2s ease;
|
||||||
}
|
}
|
||||||
@keyframes toast-in {
|
@keyframes toast-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 8px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user