From 1ecf5ae7d39d557552671193944ede17ed2d250b Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 17 Aug 2026 23:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.0=20=E5=B7=AE=E5=BC=82=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=20worker=20=E5=8C=96=20-=20=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20diff=20=E7=A7=BB=E5=85=A5=20Worker=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=AE=A1=E7=AE=97=E4=B8=8D=E9=98=BB=E5=A1=9E=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E3=80=81=E8=BE=93=E5=85=A5=E5=8E=BB=E6=8A=96=E4=B8=8E?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E4=BB=BB=E5=8A=A1=E4=B8=A2=E5=BC=83=EF=BC=88?= =?UTF-8?q?=E7=BB=88=E6=AD=A2=E9=87=8D=E5=BB=BA=E5=AE=9E=E7=8E=B0=E5=8F=96?= =?UTF-8?q?=E6=B6=88=EF=BC=89=E3=80=81=E5=B0=8F=E8=BE=93=E5=85=A5=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=BF=AB=E8=B7=AF=E5=BE=84=E3=80=81=E6=97=A0=20Worker?= =?UTF-8?q?=20=E7=8E=AF=E5=A2=83=E5=9B=9E=E9=80=80=E3=80=81=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E4=B8=AD=E9=81=AE=E7=BD=A9=E4=B8=8E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/应用开发与版本迭代规范.md | 7 +- docs/测试策略与方案.md | 20 +- package.json | 2 +- src/renderer/index.html | 2 +- src/renderer/src/App.tsx | 49 +++-- src/renderer/src/__tests__/App.test.tsx | 21 +++ src/renderer/src/components/Toolbar.test.tsx | 8 +- src/renderer/src/components/Toolbar.tsx | 10 +- .../src/diff/createDiffWorker.test.ts | 36 ++++ src/renderer/src/diff/createDiffWorker.ts | 17 ++ src/renderer/src/diff/diffWorker.ts | 27 +++ src/renderer/src/diff/textUtils.ts | 2 +- src/renderer/src/hooks/useDiff.test.ts | 174 ++++++++++++++++++ src/renderer/src/hooks/useDiff.ts | 77 ++++++++ src/renderer/src/styles/global.css | 37 ++++ vitest.config.ts | 8 +- 17 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/diff/createDiffWorker.test.ts create mode 100644 src/renderer/src/diff/createDiffWorker.ts create mode 100644 src/renderer/src/diff/diffWorker.ts create mode 100644 src/renderer/src/hooks/useDiff.test.ts create mode 100644 src/renderer/src/hooks/useDiff.ts diff --git a/README.md b/README.md index 4811203..ceb4920 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 +- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(含去抖与过期任务丢弃),界面始终保持流畅响应 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 @@ -37,6 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 | 构建 | electron-vite (Vite 5) | | 打包 | electron-builder | | Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff) | +| 后台计算 | Web Worker(Vite 内联打包) | | 编码 | iconv-lite | --- @@ -93,7 +95,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.3.2 +- 版本:0.4.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index ba86198..20b3e18 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.3.2`** +当前基线版本:**`0.4.0`** --- @@ -65,8 +65,9 @@ z = 补丁版本号(Patch) 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 -0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 -0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) +0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 +0.4.0 ← 差异计算 worker 化(当前):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用 +0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index c216b89..bd1f04a 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -47,15 +47,22 @@ src/renderer/src/ │ └── setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup ├── diff/ │ ├── diffEngine.test.ts # diff 引擎单元测试 +│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖) +│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) +│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── report.test.ts # 报告生成测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试 +├── hooks/ +│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理) +│ ├── useDiff.ts +│ └── useDismiss.ts ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 -│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单) +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单/计算中禁用) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -98,6 +105,15 @@ src/renderer/src/ - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 +- 大输入(超快路径阈值)在无 Worker 环境回退同步计算,无计算中遮罩 + +### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) +- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 +- 重路径:进入 computing 且 diff 置空;200ms 去抖后才派发 worker;响应按 jobId 匹配,错误/过期 jobId 被丢弃 +- 输入变更:旧 worker 终止重建(等价取消);去抖期内变更则旧任务未派发即取消(零浪费计算) +- worker 不可用(环境缺失/工厂返回 null):主线程同步回退 +- 卸载:终止 worker 并清理去抖定时器 +- 工厂:无 Worker 环境返回 null;可用环境返回实例 --- diff --git a/package.json b/package.json index ab3a362..6b81822 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.3.2", + "version": "0.4.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/index.html b/src/renderer/index.html index 557079f..f5ad805 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2a0b714..eea4503 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,8 +7,7 @@ import { type ReactElement, type MouseEvent as ReactMouseEvent } from 'react' -import { computeDiff } from './diff/diffEngine' -import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine' +import type { DiffOptions, DiffSummary } from './diff/diffEngine' import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView' import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' @@ -16,6 +15,7 @@ import TextInputModal from './components/TextInputModal' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { useDismiss } from './hooks/useDismiss' +import { useDiff } from './hooks/useDiff' import type { PaneMeta } from './components/DiffView' import type { SideCell } from './diff/diffEngine' @@ -31,6 +31,9 @@ function stamp(): string { /** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ const MAX_FILE_SIZE = 10 * 1024 * 1024 +/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */ +const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 } + interface PaneState { meta: PaneMeta path: string @@ -125,11 +128,11 @@ export default function App(): ReactElement { } }, []) - // 超多行预警:行数超过阈值时提示计算耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) + // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面) const warnHeavyLines = useCallback( (text: string): void => { const lines = countLines(text) - if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`) + if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),差异将在后台计算,可能需要一些时间`) }, [showToast] ) @@ -205,28 +208,28 @@ export default function App(): ReactElement { [showToast, warnHeavyLines] ) - const diff: DiffResult = useMemo(() => { - return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options) - }, [paneL, paneR, options]) + // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径 + const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options) - const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff]) - const summary: DiffSummary = diff.summary + const rows = useMemo(() => diff?.rows ?? [], [diff]) + const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows]) + const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY // “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位) const displayRows = useMemo(() => { - if (!onlyDiff) return diff.rows as DisplayItem[] - if (diff.rows.length === 0) return [] as DisplayItem[] + if (!onlyDiff) return rows as DisplayItem[] + if (rows.length === 0) return [] as DisplayItem[] if (changedRows.length === 0) { const m: FoldMarker = { fold: true, - skipped: diff.rows.length, + skipped: rows.length, note: '两文件内容完全一致,已折叠全部未更改行' } return [m] } const out: DisplayItem[] = [] let skipped = 0 - for (const r of diff.rows) { + for (const r of rows) { if (r.isChanged) { if (skipped > 0) { out.push({ fold: true, skipped }) @@ -239,7 +242,7 @@ export default function App(): ReactElement { } if (skipped > 0) out.push({ fold: true, skipped }) return out - }, [diff, onlyDiff, changedRows.length]) + }, [rows, onlyDiff, changedRows.length]) // 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新) const swapPanes = useCallback((): void => { @@ -308,6 +311,8 @@ export default function App(): ReactElement { const exportReport = useCallback( async (fmt: ReportFormat) => { + // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 + if (!diff) return const ctx: ReportContext = { leftName: paneL?.meta.name ?? '', rightName: paneR?.meta.name ?? '', @@ -316,7 +321,7 @@ export default function App(): ReactElement { leftPath: paneL?.path || undefined, rightPath: paneR?.path || undefined } - const content = buildReport(diff.rows, ctx, summary, fmt) + const content = buildReport(rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` let res: { ok: boolean; path: string | null } try { @@ -333,7 +338,7 @@ export default function App(): ReactElement { }) } }, - [diff, paneL, paneR, summary, showToast] + [diff, rows, paneL, paneR, summary, showToast] ) const anyPane = paneL !== null || paneR !== null @@ -438,6 +443,7 @@ export default function App(): ReactElement { navCount={changedRows.length} onNav={go} onExport={(fmt) => void exportReport(fmt)} + computing={computing} onlyDiff={onlyDiff} onOnlyDiffChange={setOnlyDiff} onSwap={swapPanes} @@ -464,7 +470,9 @@ export default function App(): ReactElement {
- {summary.changedLines === 0 ? ( + {computing ? ( + 正在计算差异… + ) : summary.changedLines === 0 ? ( 两文件内容完全一致 ) : ( 有差异 @@ -472,6 +480,13 @@ export default function App(): ReactElement {
+ {computing && ( +
+ + 正在计算差异… +
+ )} + {menu && setMenu(null)} />} {pasteSide && ( { }) }) +describe('App - 大输入与 worker 回退', () => { + it('超过快路径阈值的文件在无 Worker 环境回退同步计算', async () => { + const big = Array.from({ length: 2100 }, (_, i) => `row${i}`).join('\n') + window.api = mockApi({ + openFile: async () => ({ + path: '/tmp/big.txt', + name: 'big.txt', + text: big, + encoding: 'UTF-8', + binary: false + }) + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText('导出报告')).toBeInTheDocument() + // jsdom 无 Worker:同步回退完成计算,不出现计算中遮罩,行已渲染 + expect(screen.queryByText('正在计算差异…')).not.toBeInTheDocument() + expect(screen.getAllByText(/row0/).length).toBeGreaterThan(0) + }) +}) + describe('App - 粘贴下拉交互', () => { it('按 Esc 关闭粘贴下拉', () => { render() diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 92883d2..9e1715a 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine' const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } -function setup() { +function setup(extra?: { computing?: boolean }) { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() @@ -20,6 +20,7 @@ function setup() { navCount={3} onNav={onNav} onExport={onExport} + computing={extra?.computing ?? false} onlyDiff={false} onOnlyDiffChange={onOnlyDiffChange} onSwap={onSwap} @@ -100,4 +101,9 @@ describe('Toolbar', () => { fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument() }) + + it('后台计算中禁用导出按钮', () => { + setup({ computing: true }) + expect(screen.getByText('导出报告')).toBeDisabled() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 6853d28..1858f11 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -17,6 +17,8 @@ interface ToolbarProps { navCount: number onNav: (dir: 1 | -1) => void onExport: (fmt: ReportFormat) => void + /** 后台差异计算中(导出不可用) */ + computing: boolean /** 仅看差异视图开关 */ onlyDiff: boolean onOnlyDiffChange: (v: boolean) => void @@ -32,6 +34,7 @@ export default function Toolbar({ navCount, onNav, onExport, + computing, onlyDiff, onOnlyDiffChange, onSwap @@ -113,7 +116,12 @@ export default function Toolbar({
- {exportOpen && ( diff --git a/src/renderer/src/diff/createDiffWorker.test.ts b/src/renderer/src/diff/createDiffWorker.test.ts new file mode 100644 index 0000000..f0ba7bb --- /dev/null +++ b/src/renderer/src/diff/createDiffWorker.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { createDiffWorker } from './createDiffWorker' + +describe('createDiffWorker', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('环境不支持 Worker 时返回 null(jsdom 回退路径)', () => { + expect(typeof Worker).toBe('undefined') + expect(createDiffWorker()).toBeNull() + }) + + it('环境支持时返回 worker 实例', () => { + const created: unknown[] = [] + class FakeWorker { + constructor() { + created.push(this) + } + } + vi.stubGlobal('Worker', FakeWorker) + // jsdom 无 URL.createObjectURL,直接定义补桩(spyOn 要求属性已存在) + Object.defineProperty(URL, 'createObjectURL', { + value: (): string => 'blob:fake-worker', + configurable: true, + writable: true + }) + try { + const w = createDiffWorker() + expect(w).not.toBeNull() + expect(created).toHaveLength(1) + } finally { + delete (URL as { createObjectURL?: unknown }).createObjectURL + } + }) +}) diff --git a/src/renderer/src/diff/createDiffWorker.ts b/src/renderer/src/diff/createDiffWorker.ts new file mode 100644 index 0000000..c4dc84c --- /dev/null +++ b/src/renderer/src/diff/createDiffWorker.ts @@ -0,0 +1,17 @@ +import DiffWorker from './diffWorker?worker&inline' + +export type DiffWorkerFactory = () => Worker | null + +/** + * 创建 diff 计算 worker。 + * 采用 Vite 内联打包(base64 blob),兼容生产环境 file:// 加载(index.html 的 CSP 已放行 worker-src blob:)。 + * 环境不支持 Worker(如 jsdom 测试环境)或实例化失败时返回 null,由调用方回退主线程同步计算。 + */ +export const createDiffWorker: DiffWorkerFactory = () => { + if (typeof Worker === 'undefined') return null + try { + return new DiffWorker() + } catch { + return null + } +} diff --git a/src/renderer/src/diff/diffWorker.ts b/src/renderer/src/diff/diffWorker.ts new file mode 100644 index 0000000..df5b5e7 --- /dev/null +++ b/src/renderer/src/diff/diffWorker.ts @@ -0,0 +1,27 @@ +import { computeDiff } from './diffEngine' +import type { DiffOptions, DiffResult } from './diffEngine' + +/** worker 请求:jobId 用于匹配响应与丢弃过期任务(输入已变化时的旧计算结果) */ +export interface DiffWorkerRequest { + jobId: number + leftText: string + rightText: string + options: DiffOptions +} + +/** worker 响应:携带对应请求的 jobId */ +export interface DiffWorkerResponse { + jobId: number + result: DiffResult +} + +// worker 全局对象与页面 DOM 类型环境互斥,用结构化声明规避 lib 冲突 +const ctx = self as unknown as { + addEventListener(type: 'message', cb: (e: MessageEvent) => void): void + postMessage(msg: DiffWorkerResponse): void +} + +ctx.addEventListener('message', (e) => { + const { jobId, leftText, rightText, options } = e.data + ctx.postMessage({ jobId, result: computeDiff(leftText, rightText, options) }) +}) diff --git a/src/renderer/src/diff/textUtils.ts b/src/renderer/src/diff/textUtils.ts index d24eaf2..fd83d88 100644 --- a/src/renderer/src/diff/textUtils.ts +++ b/src/renderer/src/diff/textUtils.ts @@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean { return TEXT_EXTENSIONS.includes(ext) } -/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) */ +/** 超多行预警阈值:超过该行数时提示后台计算可能耗时(diff 计算 worker 化后不阻塞界面) */ export const HEAVY_LINES = 50000 /** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */ diff --git a/src/renderer/src/hooks/useDiff.test.ts b/src/renderer/src/hooks/useDiff.test.ts new file mode 100644 index 0000000..7a4d189 --- /dev/null +++ b/src/renderer/src/hooks/useDiff.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDiff } from './useDiff' +import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine' +import type { DiffWorkerFactory } from '../diff/createDiffWorker' + +/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */ +class FakeWorker { + onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null + terminated = false + posted: { jobId: number }[] = [] + postMessage(data: { jobId: number }): void { + this.posted.push(data) + } + terminate(): void { + this.terminated = true + } + respond(jobId: number, result: DiffResult): void { + if (this.terminated) return + this.onmessage?.({ data: { jobId, result } }) + } +} + +/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */ +function makeFactory(): DiffWorkerFactory & { workers: FakeWorker[] } { + const workers: FakeWorker[] = [] + const f = (() => { + const w = new FakeWorker() + workers.push(w) + return w as unknown as Worker + }) as DiffWorkerFactory & { workers: FakeWorker[] } + f.workers = workers + return f +} + +function heavyText(lines: number): string { + return Array.from({ length: lines }, (_, i) => `L${i}`).join('\n') +} + +// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑, +// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定) +const NO_OPTIONS: DiffOptions = {} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('useDiff - 快路径', () => { + it('小输入同步计算,不创建 worker', () => { + const factory = vi.fn(makeFactory()) + const { result } = renderHook(() => useDiff('a\nb', 'a\nc', NO_OPTIONS, factory)) + expect(result.current.diff).not.toBeNull() + expect(result.current.diff!.summary.changedLines).toBe(1) + expect(result.current.computing).toBe(false) + expect(factory).not.toHaveBeenCalled() + }) + + it('结果与直接调用 computeDiff 一致', () => { + const { result } = renderHook(() => useDiff('x', 'y', { ignoreCase: true })) + expect(result.current.diff).toEqual(computeDiff('x', 'y', { ignoreCase: true })) + }) +}) + +describe('useDiff - worker 后台计算', () => { + it('重输入先进入 computing,去抖后派发,回传后更新', () => { + const factory = makeFactory() + const left = heavyText(1100) + const right = heavyText(1100) + const { result } = renderHook(() => useDiff(left, right, NO_OPTIONS, factory)) + expect(result.current.computing).toBe(true) + expect(result.current.diff).toBeNull() + // 去抖期内尚未派发 + expect(factory.workers[0].posted).toHaveLength(0) + act(() => { + vi.advanceTimersByTime(250) + }) + const w = factory.workers[0] + expect(w.posted).toHaveLength(1) + const jobId = w.posted[0].jobId + // 错误 jobId 的响应被丢弃 + act(() => { + w.respond(999, computeDiff('a', 'a', {})) + }) + expect(result.current.diff).toBeNull() + const res = computeDiff('p', 'q', {}) + act(() => { + w.respond(jobId, res) + }) + expect(result.current.diff).toEqual(res) + expect(result.current.computing).toBe(false) + }) + + it('输入变化后旧 worker 被终止且迟到响应被丢弃', () => { + const factory = makeFactory() + const left = heavyText(1100) + const right = heavyText(1100) + const hook = renderHook( + ({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory), + { initialProps: { options: { ignoreCase: false } } } + ) + act(() => { + vi.advanceTimersByTime(250) + }) + const w1 = factory.workers[0] + const jobId1 = w1.posted[0].jobId + // 变更选项 → 新任务 + hook.rerender({ options: { ignoreCase: true } }) + expect(w1.terminated).toBe(true) + expect(hook.result.current.computing).toBe(true) + expect(hook.result.current.diff).toBeNull() + // 旧 worker 的迟到响应被丢弃 + act(() => { + w1.respond(jobId1, computeDiff('a', 'a', {})) + }) + expect(hook.result.current.diff).toBeNull() + // 新 worker 的结果生效 + const w2 = factory.workers[1] + act(() => { + vi.advanceTimersByTime(250) + }) + const jobId2 = w2.posted[0].jobId + expect(jobId2).toBeGreaterThan(jobId1) + const res2 = computeDiff('m', 'n', {}) + act(() => { + w2.respond(jobId2, res2) + }) + expect(hook.result.current.diff).toEqual(res2) + expect(hook.result.current.computing).toBe(false) + }) + + it('去抖期内输入变更:旧任务未派发即取消(零浪费计算)', () => { + const factory = makeFactory() + const hook = renderHook((p: string) => useDiff(p, '', NO_OPTIONS, factory), { + initialProps: heavyText(2100) + }) + act(() => { + vi.advanceTimersByTime(100) + }) + hook.rerender(heavyText(2300)) + expect(factory.workers[0].posted).toHaveLength(0) + expect(factory.workers[0].terminated).toBe(true) + act(() => { + vi.advanceTimersByTime(300) + }) + expect(factory.workers[1].posted).toHaveLength(1) + }) + + it('worker 不可用时回退主线程同步计算', () => { + const left = heavyText(2100) + // 工厂必须稳定引用:内联箭头函数每次渲染变引用会引发 effect 无限重跑 + const nullFactory: DiffWorkerFactory = () => null + const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, nullFactory)) + expect(result.current.diff).toEqual(computeDiff(left, 'x', {})) + expect(result.current.computing).toBe(false) + }) + + it('卸载时终止 worker 并清理去抖定时器', () => { + const factory = makeFactory() + const left = heavyText(2100) + const hook = renderHook(() => useDiff(left, '', NO_OPTIONS, factory)) + const w = factory.workers[0] + hook.unmount() + expect(w.terminated).toBe(true) + // 卸载后去抖到期不再派发 + act(() => { + vi.advanceTimersByTime(300) + }) + expect(w.posted).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/hooks/useDiff.ts b/src/renderer/src/hooks/useDiff.ts new file mode 100644 index 0000000..016b232 --- /dev/null +++ b/src/renderer/src/hooks/useDiff.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { computeDiff, 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' + +/** 快路径行数上限:两侧总行数不超过该值时在渲染线程同步计算(毫秒级,避免选项开关切换时闪烁加载态) */ +const FAST_PATH_MAX_LINES = 2000 +/** 重输入去抖:合并连续的选项/内容变更,超时后才派发 worker 计算(去抖期内取消 = 零浪费计算) */ +const DEBOUNCE_MS = 200 + +export interface DiffState { + /** 差异结果;重输入计算期间为 null(配 computing 展示加载态) */ + diff: DiffResult | null + /** 是否正在后台计算(仅重路径会出现 true) */ + computing: boolean +} + +/** + * worker 化的 diff 计算 hook。 + * 小输入同步快路径(行为与 worker 化之前完全一致); + * 大输入去抖后派发 worker 后台计算,输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消), + * 响应按 jobId 匹配丢弃过期任务;worker 不可用的环境回退主线程同步计算。 + * workerFactory 仅测试注入用,生产调用保持缺省;注入时需保证引用稳定。 + */ +export function useDiff( + left: string, + right: string, + options: DiffOptions, + workerFactory?: DiffWorkerFactory +): DiffState { + const heavy = useMemo( + () => countLines(left) + countLines(right) > FAST_PATH_MAX_LINES, + [left, right] + ) + const factory = workerFactory ?? createDiffWorker + + // 快路径:同步计算,渲染当帧即得结果 + const fastDiff = useMemo(() => { + if (!heavy) return computeDiff(left, right, options) + return null + }, [left, right, options, heavy]) + + const [asyncDiff, setAsyncDiff] = useState(null) + const [computing, setComputing] = useState(false) + const jobIdRef = useRef(0) + + useEffect(() => { + if (!heavy) return + // 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异 + setAsyncDiff(null) + const w = factory() + if (!w) { + // 无 Worker 支持(jsdom/老环境):主线程同步回退 + setAsyncDiff(computeDiff(left, right, options)) + setComputing(false) + return + } + const jobId = ++jobIdRef.current + setComputing(true) + const timer = window.setTimeout(() => { + w.onmessage = (e: MessageEvent): void => { + // 丢弃过期任务:输入已变化时旧计算结果不再生效 + if (e.data.jobId !== jobId) return + setAsyncDiff(e.data.result) + setComputing(false) + } + w.postMessage({ jobId, leftText: left, rightText: right, options }) + }, DEBOUNCE_MS) + return () => { + window.clearTimeout(timer) + w.terminate() + } + }, [left, right, options, heavy, factory]) + + return heavy ? { diff: asyncDiff, computing } : { diff: fastDiff, computing: false } +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 1cb7e3e..4578d9c 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -89,6 +89,7 @@ button:disabled { display: flex; flex-direction: column; height: 100%; + position: relative; } .app-header { @@ -739,4 +740,40 @@ button:disabled { .eq-badge.diff { color: var(--muted); background: var(--surface); +} +.eq-badge.busy { + color: var(--accent-2); + background: rgba(34, 211, 238, 0.12); +} + +/* ============ 后台计算遮罩 ============ */ +.computing-overlay { + position: absolute; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: rgba(7, 11, 18, 0.55); + backdrop-filter: blur(2px); + color: var(--text); + font-size: 13px; + letter-spacing: 1px; + pointer-events: none; + user-select: none; +} +.computing-ring { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(148, 163, 255, 0.25); + border-top-color: var(--accent-2); + animation: computing-spin 0.8s linear infinite; + flex: none; +} +@keyframes computing-spin { + to { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index cad0ee9..53a7664 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,7 +19,13 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'text-summary'], include: ['src/renderer/src/**/*.{ts,tsx}'], - exclude: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/test/**', 'src/renderer/src/env.d.ts'], + exclude: [ + 'src/renderer/src/**/*.{test,spec}.{ts,tsx}', + 'src/renderer/src/test/**', + 'src/renderer/src/env.d.ts', + // worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖 + 'src/renderer/src/diff/diffWorker.ts' + ], all: true, // CI 门禁:四项覆盖率不得低于 80%,否则测试失败 thresholds: {