feat: 0.9.0 忽略规则、增量缓存跨会话持久化、快照基线、字符级预算自适应与性能基准E2E - 忽略规则(gitignore简化子集:目录整树剪枝不枚举不计数、扩展名通配与名字匹配、随偏好持久化)、扫描增量缓存落盘userData跨会话自动注入(LRU 8对、防御性解析、渲染端零改动)、快照基线(保存/加载JSON对照六类状态变迁统计与条目标注、对照随重扫刷新、报告附快照口径)、字符级变更组预算随输入规模自适应(下限与固定值一致小输入零变化、大输入按总组流半数放宽上限100万封顶)、大文件性能基准E2E(5万行<15s与300KB GBK<10s耗时断言)+新功能E2E、文档与版本号同步

This commit is contained in:
2026-08-18 22:58:00 +08:00
parent fa13160848
commit 35fa81df9d
26 changed files with 2525 additions and 550 deletions
+55 -5
View File
@@ -6,6 +6,14 @@ import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan'
import { isIgnoreRulesArray } from './ignoreRules'
import {
parseScanCacheStore,
putScanCache,
getScanCache,
emptyScanCacheStore,
type ScanCacheStoreData
} from './scanCacheStore'
/**
* 文本类扩展名清单(小写,不含点):
@@ -30,6 +38,35 @@ function isTextRel(rel: string): boolean {
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
/** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */
const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json')
/** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描 */
function loadScanCacheStore(): ScanCacheStoreData {
try {
return parseScanCacheStore(fs.readFileSync(scanCacheFile(), 'utf-8'))
} catch {
return emptyScanCacheStore()
}
}
/**
* 落盘扫描缓存(读-改-写,异步 fire-and-forget):
* 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。
*/
async function persistScanCache(
leftDir: string,
rightDir: string,
cache: ScanCacheEntry[]
): Promise<void> {
try {
const next = putScanCache(loadScanCacheStore(), leftDir, rightDir, cache, Date.now())
await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8')
} catch {
// 忽略写入失败
}
}
/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */
let appState: AppState = {}
@@ -264,7 +301,7 @@ ipcMain.handle('folder:pick', async () => {
return dirPath
})
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 增量缓存;异步 fs 与解码 worker 不阻塞主进程) */
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程) */
ipcMain.handle(
'folder:scan',
async (
@@ -272,10 +309,11 @@ ipcMain.handle(
leftDir: string,
rightDir: string,
semantic: boolean,
previous?: unknown
previous?: unknown,
ignoreRules?: unknown
) => {
try {
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即全量重扫
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即不沿用
const cache =
Array.isArray(previous) && previous.every(
(c) =>
@@ -286,8 +324,17 @@ ipcMain.handle(
)
? (previous as ScanCacheEntry[])
: undefined
return await scanFolders(leftDir, rightDir, {
...(cache ? { previous: cache } : {}),
// 渲染端未携带内存缓存时(跨会话首次扫描),尝试注入 userData 落盘的持久化缓存
let prev = cache
if (prev === undefined) {
const stored = getScanCache(loadScanCacheStore(), leftDir, rightDir)
if (stored !== null && stored.length > 0) prev = stored
}
// 忽略规则防御性校验:仅接受字符串数组形态(渲染端从偏好携带,损坏即忽略)
const ignore = isIgnoreRulesArray(ignoreRules) ? ignoreRules : undefined
const result = await scanFolders(leftDir, rightDir, {
...(prev ? { previous: prev } : {}),
...(ignore ? { ignore } : {}),
...(semantic
? {
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
@@ -299,6 +346,9 @@ ipcMain.handle(
}
: {})
})
// 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫)
if (!result.truncated) void persistScanCache(leftDir, rightDir, result.cache)
return result
} catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const }