Compare commits

...
3 Commits
14 changed files with 602 additions and 54 deletions
+4 -3
View File
@@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃),界面始终保持流畅响应
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角
@@ -20,7 +20,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,下次启动自动恢复(损坏数据自动回退默认)
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
- **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失
- **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
@@ -99,7 +100,7 @@ src/
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.2
- 版本:0.5.3
---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.5.2`**
当前基线版本:**`0.5.3`**
---
@@ -72,7 +72,8 @@ z = 补丁版本号(Patch
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
0.5.2 ← 稳健性收尾与偏好记忆(当前):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
0.5.2 ← 稳健性收尾与偏好记忆(已发布):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
0.5.3 ← 稳健性补漏与偏好记忆收尾(当前):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
...
0.y.z ← 长期停留,永不进入 1.x
+15 -2
View File
@@ -42,6 +42,10 @@
```
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
src/main/
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,随发布人工自测)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/
├── test/
│ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup
@@ -53,7 +57,7 @@ src/renderer/src/
│ ├── report.test.ts # 报告生成测试
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理)
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径
│ ├── useDiff.ts
│ └── useDismiss.ts
├── components/
@@ -62,7 +66,7 @@ src/renderer/src/
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退)
└── main.test.ts # 入口挂载冒烟测试
```
@@ -109,6 +113,9 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换
- toast 队列:连续多条提示同时可见不互顶 / 各自独立计时消失 / 超过上限顶掉最旧 / 动作按钮点击后该条立即关闭 / 二进制预警与行数预警共存
- 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认
- 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
@@ -117,12 +124,18 @@ src/renderer/src/
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
- 重路径:进入 computing 且 diff 置空;200ms 去抖到期后才创建 worker 并派发;响应按 jobId 匹配,错误/过期 jobId 被丢弃
- 字符级大字符量重路径:charMode 开启且两侧字符总量超过阈值(少行大文件,行数维度防不住 diffChars 耗时)同样进入重路径;未超阈值/charMode 关闭不受影响;恰好阈值不触发
- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销)
- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理
- worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效
- 工厂:无 Worker 环境返回 null;可用环境返回实例
### 5.7 应用状态持久化(`src/main/windowState.ts`
- parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略
- clampBounds:工作区内合法 bounds 原样保留;小于最小尺寸钳到最小值;超出工作区封顶;移出屏幕四向拉回(至少 60px 进入工作区);多显示器偏移工作区同样钳制;非整数取整;工作区小于最小尺寸时尺寸仍保持最小值
- 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,随发布人工自测)
---
## 6. 运行命令
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.5.2",
"version": "0.5.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+50 -4
View File
@@ -1,14 +1,44 @@
import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { join, dirname } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */
let appState: AppState = {}
/** 读取持久化状态:文件不存在/损坏一律回退空状态,不影响启动 */
function loadState(): AppState {
try {
return parseState(fs.readFileSync(stateFile(), 'utf-8'))
} catch {
return {}
}
}
/** 写回持久化状态:失败静默忽略(目录只读/磁盘满等,偏好记忆失效但不影响本次会话) */
function saveState(): void {
try {
fs.writeFileSync(stateFile(), JSON.stringify(appState), 'utf-8')
} catch {
// 忽略写入失败
}
}
function createWindow(): void {
// 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸
const wa = screen.getPrimaryDisplay().workArea
const bounds = appState.bounds ? clampBounds(appState.bounds, wa) : null
// 主窗口
const mainWindow = new BrowserWindow({
width: 1280,
height: 820,
width: bounds?.width ?? 1280,
height: bounds?.height ?? 820,
x: bounds?.x,
y: bounds?.y,
minWidth: 900,
minHeight: 600,
show: false,
@@ -24,6 +54,14 @@ function createWindow(): void {
}
})
// 关闭时记忆窗口普通状态 bounds(最大化/全屏/最小化时不记录,避免把超屏尺寸存进偏好)
mainWindow.on('close', () => {
if (mainWindow.isMinimized() || mainWindow.isMaximized() || mainWindow.isFullScreen()) return
const b = mainWindow.getBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveState()
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
@@ -98,6 +136,8 @@ function decodeText(buf: Buffer): { text: string; encoding: string; binary: bool
ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const result = await dialog.showOpenDialog({
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
// 记忆上次成功打开文件所在目录;无记录时传 undefined 走系统默认(最近使用位置)
defaultPath: appState.lastDir,
properties: ['openFile'],
filters: [
// 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步
@@ -108,6 +148,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
@@ -163,6 +206,9 @@ app.whenReady().then(() => {
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
nativeTheme.themeSource = 'dark'
// 读取持久化应用状态(窗口 bounds + 上次文件目录;损坏数据回退空状态)
appState = loadState()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { parseState, clampBounds, MIN_WIDTH, MIN_HEIGHT, type Rect } from './windowState'
/** 1920×1040 工作区(常见全高清屏去掉任务栏) */
const work: Rect = { x: 0, y: 0, width: 1920, height: 1040 }
describe('parseState', () => {
it('空输入回退空状态', () => {
expect(parseState(null)).toEqual({})
expect(parseState(undefined)).toEqual({})
expect(parseState('')).toEqual({})
})
it('非法 JSON 回退空状态', () => {
expect(parseState('{broken json!!')).toEqual({})
})
it('合法 bounds 与 lastDir 被保留', () => {
const s = parseState(
JSON.stringify({ bounds: { x: 10, y: 20, width: 1000, height: 700 }, lastDir: 'C:/docs' })
)
expect(s.bounds).toEqual({ x: 10, y: 20, width: 1000, height: 700 })
expect(s.lastDir).toBe('C:/docs')
})
it('bounds 字段类型异常或缺失时被忽略', () => {
expect(parseState(JSON.stringify({ bounds: { x: 'a', y: 2, width: 1000, height: 700 } })).bounds).toBeUndefined()
expect(parseState(JSON.stringify({ bounds: { x: 1, y: 2, width: 1000 } })).bounds).toBeUndefined()
expect(parseState(JSON.stringify({ bounds: null })).bounds).toBeUndefined()
})
it('bounds 含 NaN/Infinity 等非有限数值时被忽略', () => {
const s = parseState(
'{"bounds":{"x":NaN,"y":2,"width":1000,"height":700}}'
)
expect(s.bounds).toBeUndefined()
})
it('lastDir 非字符串或空串时被忽略', () => {
expect(parseState(JSON.stringify({ lastDir: 42 })).lastDir).toBeUndefined()
expect(parseState(JSON.stringify({ lastDir: '' })).lastDir).toBeUndefined()
})
})
describe('clampBounds', () => {
it('工作区内合法 bounds 原样保留', () => {
const b = { x: 100, y: 50, width: 1280, height: 820 }
expect(clampBounds(b, work)).toEqual(b)
})
it('小于最小尺寸时钳制到最小值', () => {
const c = clampBounds({ x: 100, y: 100, width: 400, height: 300 }, work)
expect(c.width).toBe(MIN_WIDTH)
expect(c.height).toBe(MIN_HEIGHT)
})
it('超出工作区尺寸时封顶到工作区大小', () => {
const c = clampBounds({ x: 0, y: 0, width: 4000, height: 3000 }, work)
expect(c.width).toBe(work.width)
expect(c.height).toBe(work.height)
})
it('窗口移出屏幕左侧时拉回(右边缘至少 60px 进入工作区)', () => {
const c = clampBounds({ x: -2000, y: 100, width: 1000, height: 700 }, work)
expect(c.x).toBe(work.x - 1000 + 60)
})
it('窗口移出屏幕右侧时拉回(左边缘至少 60px 进入工作区)', () => {
const c = clampBounds({ x: 5000, y: 100, width: 1000, height: 700 }, work)
expect(c.x).toBe(work.x + work.width - 60)
})
it('窗口移出屏幕下方时拉回', () => {
const c = clampBounds({ x: 100, y: 5000, width: 1000, height: 700 }, work)
expect(c.y).toBe(work.y + work.height - 60)
})
it('带偏移的工作区(多显示器副屏)同样钳制', () => {
const side: Rect = { x: 1920, y: 200, width: 1080, height: 900 }
const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, side)
// 左边缘至少 60px 进入副屏
expect(c.x).toBe(side.x - 1000 + 60)
})
it('非整数尺寸与坐标被取整', () => {
const c = clampBounds({ x: 10.6, y: 20.2, width: 1000.7, height: 700.1 }, work)
expect(Number.isInteger(c.x)).toBe(true)
expect(Number.isInteger(c.y)).toBe(true)
expect(Number.isInteger(c.width)).toBe(true)
expect(Number.isInteger(c.height)).toBe(true)
})
it('工作区小于最小尺寸时尺寸仍保持最小值', () => {
const tiny: Rect = { x: 0, y: 0, width: 800, height: 500 }
const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, tiny)
expect(c.width).toBe(MIN_WIDTH)
expect(c.height).toBe(MIN_HEIGHT)
})
})
+70
View File
@@ -0,0 +1,70 @@
/**
* 应用状态持久化的纯数据模块:窗口尺寸/位置记忆 + 文件对话框上次目录记忆。
* 不依赖 electron 与 fs(磁盘读写由主进程调用方完成),校验逻辑可被 vitest 直接测试。
*/
/** 矩形区域(窗口 bounds 与屏幕工作区共用结构,字段与 Electron Rectangle 一致) */
export interface Rect {
x: number
y: number
width: number
height: number
}
/** 持久化的应用状态 */
export interface AppState {
/** 上次关闭时的窗口 bounds */
bounds?: Rect
/** 上次成功打开文件所在目录(file:open 对话框 defaultPath */
lastDir?: string
}
/** 最小窗口尺寸(与 BrowserWindow 的 minWidth/minHeight 保持一致,改动需双向同步) */
export const MIN_WIDTH = 900
export const MIN_HEIGHT = 600
function isRect(v: unknown): v is Rect {
if (typeof v !== 'object' || v === null) return false
const r = v as Record<string, unknown>
return (
typeof r.x === 'number' &&
Number.isFinite(r.x) &&
typeof r.y === 'number' &&
Number.isFinite(r.y) &&
typeof r.width === 'number' &&
Number.isFinite(r.width) &&
typeof r.height === 'number' &&
Number.isFinite(r.height)
)
}
/**
* 解析持久化文本为状态对象:JSON 非法 / 字段类型异常一律回退空状态,
* 保证损坏数据不影响启动。
*/
export function parseState(raw: string | null | undefined): AppState {
if (!raw) return {}
try {
const obj = JSON.parse(raw) as AppState
const state: AppState = {}
if (isRect(obj.bounds)) state.bounds = obj.bounds
if (typeof obj.lastDir === 'string' && obj.lastDir !== '') state.lastDir = obj.lastDir
return state
} catch {
return {}
}
}
/**
* 把窗口 bounds 钳制到屏幕工作区:
* - 尺寸不小于最小值、不超过工作区大小(工作区小于最小值时以最小值为准);
* - 位置保证窗口至少 60px 落在工作区内(分辨率/显示器变更后不出现“看不见的窗口”);
* - 结果取整(BrowserWindow 接受整数坐标)。
*/
export function clampBounds(b: Rect, workArea: Rect): Rect {
const width = Math.min(Math.max(b.width, MIN_WIDTH), Math.max(workArea.width, MIN_WIDTH))
const height = Math.min(Math.max(b.height, MIN_HEIGHT), Math.max(workArea.height, MIN_HEIGHT))
const x = Math.min(Math.max(b.x, workArea.x - width + 60), workArea.x + workArea.width - 60)
const y = Math.min(Math.max(b.y, workArea.y - height + 60), workArea.y + workArea.height - 60)
return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) }
}
+96 -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)
@@ -426,6 +493,10 @@ export default function App(): ReactElement {
const leftMeta = paneL?.meta ?? null
const rightMeta = paneR?.meta ?? null
// 状态栏行数(面板内容不变时不重算;切分规则与 diff 引擎一致)
const leftLines = useMemo(() => (paneL ? countLines(paneL.text) : 0), [paneL])
const rightLines = useMemo(() => (paneR ? countLines(paneR.text) : 0), [paneR])
return (
<div className="app">
<header className="app-header">
@@ -472,13 +543,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>
@@ -519,9 +584,11 @@ export default function App(): ReactElement {
<div className="status-left">
<span className="status-item">
<b>{paneL ? paneL.meta.name : '—'}</b>
{paneL ? ` · ${leftLines}` : ''}
</span>
<span className="status-item">
<b>{paneR ? paneR.meta.name : '—'}</b>
{paneR ? ` · ${rightLines}` : ''}
</span>
<span className="status-item">
<b>{summary.changedLines}</b>
@@ -554,21 +621,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>
)
+159 -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 })
@@ -677,6 +791,50 @@ describe('App - 字符级对比', () => {
})
})
describe('App - 状态栏行数', () => {
// 文件名在 <b> 子元素内,getByText 只拼直接文本子节点无法跨元素匹配,
// 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽)
it('加载文件后状态栏显示对应行数(切分规则与引擎一致)', async () => {
const { container } = render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '')
// mockApi 默认返回 'line1\nline2' = 2 行;右侧未加载无行数
expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*2 行/)
expect(items()[1]).toMatch(/右:\s*—$/)
})
it('两侧均加载时各自显示行数', async () => {
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/a.txt', name: 'a.txt', text: 'a\nb\nc', encoding: 'UTF-8', binary: false }
: { path: '/tmp/b.txt', name: 'b.txt', text: 'x\ny', encoding: 'UTF-8', binary: false }
})
const { container } = render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('b.txt')
const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '')
expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*3 行/)
expect(items()[1]).toMatch(/右:\s*b\.txt\s*·\s*2 行/)
})
it('清空后行数展示随空态消失', async () => {
const { container } = render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '')
expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*2 行/)
fireEvent.click(screen.getByText('清空'))
await screen.findByText(/再次点击/)
fireEvent.click(screen.getByText('清空'))
await screen.findByText(/选择左侧文件/)
expect(items()[0]).not.toMatch(/·\s*2 行/)
})
})
describe('App - 交换左右侧', () => {
it('交换后左右面板文件互换', async () => {
window.api = mockApi({
+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);
}
}
+9 -3
View File
@@ -13,18 +13,24 @@ export default defineConfig({
environment: 'jsdom',
globals: false,
setupFiles: ['./src/renderer/src/test/setup.ts'],
include: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}'],
include: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
// 主进程纯逻辑模块(无 electron 依赖)的单元测试
'src/main/**/*.{test,spec}.{ts,tsx}'
],
exclude: ['node_modules', 'dist', 'out'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
include: ['src/renderer/src/**/*.{ts,tsx}'],
include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.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'
'src/renderer/src/diff/diffWorker.ts',
// 主进程入口依赖 electron 运行时,无法在 jsdom 测试(窗口/IPC 逻辑随发布人工自测)
'src/main/index.ts'
],
all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败