fix: 0.7.1 文件夹扫描竞态守卫与边角修复 - 扫描会话序号丢弃过期结果(并发重扫不互相覆盖、退出后迟到结果不拉回文件夹模式)、FolderView补resize监听(拉伸窗口即时补齐渲染窗口)、行号列不透明背景(真机证实横向滚动文字透过sticky半透明行号列叠印)、最大化关闭改存getNormalBounds(会话内调整的窗口位置不丢失)、行id生成统一与fmtSize收敛共用、文档与版本号同步

This commit is contained in:
2026-08-18 20:41:45 +08:00
parent 1c52f5d3cb
commit 494b2ad6ea
13 changed files with 152 additions and 31 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.7.0
- 版本:0.7.1
---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.7.0`**
当前基线版本:**`0.7.1`**
---
@@ -82,7 +82,7 @@ z = 补丁版本号(Patch
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步)
0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(当前):
0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(已发布):
- 语义判等:字节级判“不同”的文本文件(≤2MB)二次判等——剔除全部空白(换行符 CRLF/LF、缩进、空行、排版重排)后一致计为“语义同”(大小写敏感,大小写差异仍为实质差异),消除换行符误报;解码复用解码 worker(大缓冲后台解码不阻塞),与文件打开共用同一管线;开关默认开启并持久化,切换即重扫;语义同不计入差异(仅看差异过滤),双击仍可查看明细
- 子目录树形展示:条目按路径聚合为目录树(folderTree 纯逻辑模块:建树/状态聚合/扁平化/过滤),目录行展示子树聚合状态(different > unreadable > only > 语义同 > 相同 优先级)与文件计数,点击折叠/展开(默认全展开);树形扁平化行数组保持虚拟滚动;过滤/搜索后无文件的目录自动剪枝
- 搜索与状态过滤:文件名搜索框(相对路径子串、不区分大小写,命中路径强制展开);统计徽章可点击作状态精确过滤(可多选,与“仅看差异”互斥联动)
@@ -91,6 +91,7 @@ z = 补丁版本号(Patch
- 大目录单点失败降级:单文件读取失败(Windows 文件锁/Defender 占用)标记“无法读取”状态(琥珀警示徽章,默认显示),单目录不可读跳过——不再整体失败丢弃全部结果(万级目录真机冒烟验证:被锁的 electron asar 正确降级,其余 9999 项正常判定)
- 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎)
- 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持)
0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步
...
0.y.z ← 长期停留,永不进入 1.x
```
+4 -3
View File
@@ -76,11 +76,11 @@ src/renderer/src/
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动/resize 重算
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果
└── main.test.ts # 入口挂载冒烟测试
```
@@ -134,7 +134,7 @@ src/renderer/src/
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比)
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -171,6 +171,7 @@ src/renderer/src/
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
- 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加)
### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron
- 启动冒烟:标题与双空态面板、空态无导出入口
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.5.5",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.5.5",
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.7.0",
"version": "0.7.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+4 -3
View File
@@ -76,10 +76,11 @@ function createWindow(): void {
}
})
// 关闭时记忆窗口普通状态 bounds最大化/全屏/最小化时不记录,避免把超屏尺寸存进偏好)
// 关闭时记忆窗口普通状态 bounds最大化/全屏时 getNormalBounds 仍返回普通状态的
// 尺寸位置(本次会话调整过的窗口位置不因最大化关闭而丢失);最小化时不记录
mainWindow.on('close', () => {
if (mainWindow.isMinimized() || mainWindow.isMaximized() || mainWindow.isFullScreen()) return
const b = mainWindow.getBounds()
if (mainWindow.isMinimized()) return
const b = mainWindow.getNormalBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveState()
})
+17 -1
View File
@@ -353,12 +353,22 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines]
)
/**
* 文件夹扫描会话序号:并发扫描(语义切换触发重扫 / 重新选择新目录)按序号丢弃过期结果,
* 慢的旧扫描后完成不得覆盖新扫描的目录与条目、不得误弹失败提示;
* 退出文件夹对比时递增以作废在途扫描(迟到的结果不得把界面拉回文件夹模式)。
*/
const scanSeqRef = useRef(0)
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发) */
const runFolderScan = useCallback(
async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
const seq = ++scanSeqRef.current
setFolderScanning(true)
try {
const res = await window.api.scanFolder(leftDir, rightDir, semantic)
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
if (seq !== scanSeqRef.current) return
if ('error' in res) {
showToast('文件夹扫描失败,请检查目录是否存在且可访问')
return
@@ -366,9 +376,11 @@ export default function App(): ReactElement {
setFolderState({ ...res, leftDir, rightDir })
setMode('folder')
} catch {
if (seq !== scanSeqRef.current) return
showToast('文件夹扫描失败,请重试')
} finally {
setFolderScanning(false)
// 仅最新会话允许复位扫描态(新扫描进行中不被旧扫描的 finally 误清)
if (seq === scanSeqRef.current) setFolderScanning(false)
}
},
[showToast]
@@ -517,6 +529,10 @@ export default function App(): ReactElement {
/** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => {
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
// 也不得让扫描遮罩残留到下一次文件夹会话
scanSeqRef.current++
setFolderScanning(false)
setFolderState(null)
setPaneL(null)
setPaneR(null)
+79
View File
@@ -1206,6 +1206,85 @@ describe('App - 文件夹对比', () => {
})
})
describe('App - 文件夹扫描竞态', () => {
it('慢的旧扫描后完成不覆盖新扫描结果(重新选择抢先完成)', async () => {
const E1 = [{ rel: 'diff1.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E2 = [{ rel: 'stale.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E3 = [{ rel: 'fresh.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const dirs = ['C:/l1', 'D:/r1', 'C:/l2', 'D:/r2']
let pick = 0
let scan = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSlow: (v: any) => void = () => {}
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scan++
if (scan === 2) {
// 第二次扫描(语义切换触发)挂起,模拟大目录慢扫描
return new Promise((res) => {
resolveSlow = res
}) as ReturnType<typeof window.api.scanFolder>
}
return scan === 1
? { entries: E1, truncated: false, total: 1 }
: { entries: E3, truncated: false, total: 1 }
}
})
render(<App />)
// 第一轮:进入文件夹模式
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('diff1.txt')
// 语义切换触发慢扫描(在途),随后重新选择触发新目录的快扫描
fireEvent.click(screen.getByLabelText('语义判等'))
fireEvent.click(screen.getByText('重新选择'))
await screen.findByText('fresh.txt')
// 慢扫描此刻才完成:过期结果必须被丢弃(不覆盖新目录与条目)
await act(async () => {
resolveSlow({ entries: E2, truncated: false, total: 1 })
})
expect(screen.queryByText('stale.txt')).not.toBeInTheDocument()
expect(screen.getByText('fresh.txt')).toBeInTheDocument()
expect(screen.getAllByText('C:/l2').length).toBeGreaterThan(0)
// 扫描态不被旧扫描干扰(无残留遮罩)
expect(screen.queryByText(/正在扫描文件夹/)).not.toBeInTheDocument()
})
it('扫描进行中退出文件夹对比:迟到的扫描结果不把界面拉回文件夹模式', async () => {
const E1 = [{ rel: 'diff1.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E2 = [{ rel: 'stale.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const dirs = ['C:/l1', 'D:/r1']
let pick = 0
let scan = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSlow: (v: any) => void = () => {}
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scan++
if (scan === 1) return { entries: E1, truncated: false, total: 1 }
return new Promise((res) => {
resolveSlow = res
}) as ReturnType<typeof window.api.scanFolder>
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('diff1.txt')
// 语义切换触发慢扫描(在途),随即退出文件夹对比
fireEvent.click(screen.getByLabelText('语义判等'))
fireEvent.click(screen.getByText('返回文件对比'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
// 迟到的扫描完成:不得把界面拉回文件夹模式,也不得残留扫描遮罩
await act(async () => {
resolveSlow({ entries: E2, truncated: false, total: 1 })
})
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('stale.txt')).not.toBeInTheDocument()
expect(screen.queryByText(/正在扫描文件夹/)).not.toBeInTheDocument()
})
})
describe('App - 文件夹语义判等', () => {
const semEntries = [
{ rel: 'same.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
@@ -399,4 +399,24 @@ describe('FolderView - 其他', () => {
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement
expect(sizer.style.height).toBe(`${1500 * FOLDER_ROW_HEIGHT}px`)
})
it('窗口尺寸变化后按新视口高度补齐渲染窗口(resize 重算)', () => {
// 200 个根层文件(无目录,扁平行);jsdom 无布局走 600px 兜底视口
const many: FolderEntry[] = Array.from({ length: 200 }, (_, i) => ({
rel: `f${i}.txt`,
status: 'different' as const,
leftSize: 1,
rightSize: 2
}))
const { container } = renderView(many, false)
const scroll = container.querySelector('.folder-scroll') as HTMLElement
expect(scroll).not.toBeNull()
const count = (): number => container.querySelectorAll('.folder-row').length
const before = count()
expect(before).toBeGreaterThan(0)
// stub clientHeight 模拟窗口拉高(600 → 1400):resize 后渲染窗口必须扩大
Object.defineProperty(scroll, 'clientHeight', { get: () => 1400, configurable: true })
fireEvent(window, new Event('resize'))
expect(count()).toBeGreaterThan(before + 20)
})
})
+11 -9
View File
@@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -8,7 +9,7 @@ import {
} from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { type ReportFormat } from '../diff/report'
import { countFolderStats } from '../diff/folderReport'
import { countFolderStats, fmtSize } from '../diff/folderReport'
import { useDismiss } from '../hooks/useDismiss'
import { buildFolderTree, filterEntries, flattenFolderTree } from './folderTree'
@@ -70,14 +71,6 @@ const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType<typeof cou
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
]
/** 人类可读的文件大小 */
function fmtSize(n: number | null): string {
if (n === null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
export default function FolderView({
leftDir,
rightDir,
@@ -171,6 +164,15 @@ export default function FolderView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
// 窗口尺寸变化时按新视口高度补齐渲染窗口(与 DiffView 同思路;
// 缺失该监听时拉伸窗口变高后底部出现空白区,滚动一下才恢复)
useEffect(() => {
const onResize = (): void => updateRange()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
const q = query.trim()
return (
+1 -2
View File
@@ -145,7 +145,6 @@ function computeLineDiff(
rightText: string,
options: DiffOptions = {}
): DiffResult {
let rowSeq = 0
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)
@@ -180,7 +179,7 @@ function computeLineDiff(
const flushRemoved = (): void => {
for (const r of queuedRemoved) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: r.lineNo, text: r.text, segs: null },
+2 -2
View File
@@ -78,8 +78,8 @@ const STATUS_LABEL: Record<FolderEntryStatus, string> = {
unreadable: '无法读取'
}
/** 人类可读的文件大小 */
function fmtSize(n: number | null): string {
/** 人类可读的文件大小(报告与 FolderView 列表共用同一实现) */
export function fmtSize(n: number | null): string {
if (n === null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
+7 -5
View File
@@ -305,12 +305,14 @@ button:disabled {
background: rgba(0, 0, 0, 0.12);
}
/* 行号列 sticky 悬浮于内容之上背景必须不透明否则横向滚动时行文字会
透过半透明行号列叠印等效合成值 = 原半透明叠加预先合成到面板底色 #0a0e17 */
.ln {
flex: 0 0 46px;
text-align: right;
padding-right: 8px;
color: #4d5a74;
background: rgba(124, 108, 255, 0.05);
background: #101323;
border-right: 1px solid var(--border);
position: sticky;
left: 0;
@@ -332,7 +334,7 @@ button:disabled {
}
.diff-row.modified .ln {
color: var(--mod-fg);
background: rgba(245, 158, 11, 0.16);
background: #453314;
border-right-color: rgba(245, 158, 11, 0.3);
}
.diff-row.added {
@@ -340,7 +342,7 @@ button:disabled {
}
.diff-row.added .ln {
color: var(--add-fg);
background: rgba(34, 197, 94, 0.14);
background: #103c29;
border-right-color: rgba(34, 197, 94, 0.3);
}
.diff-row.removed {
@@ -348,7 +350,7 @@ button:disabled {
}
.diff-row.removed .ln {
color: var(--del-fg);
background: rgba(248, 113, 113, 0.13);
background: #42252c;
border-right-color: rgba(248, 113, 113, 0.3);
}
@@ -384,7 +386,7 @@ button:disabled {
background: rgba(148, 163, 255, 0.04);
}
.diff-row.fold .ln {
background: rgba(148, 163, 255, 0.03);
background: #141927;
}
.fold-note {
color: var(--muted);