fix: 0.9.1 快照收尾修复与稳健性增强 - 快照目录对归一化匹配(Windows同目录异写不误拦、与scanCacheStore同款逻辑双向同步)、截断结果保存快照拦截提示(防对照新文件误报)、folder:scan IPC校验抽scanIpc纯逻辑模块单测全覆盖(缓存源选择惰性读盘)、对照模式导出报告附变迁对照表(三格式当前/基线状态与大小、标签收敛SNAPSHOT_CHANGE_LABEL共享映射、E2E对照导出写盘断言)、顶层ErrorBoundary渲染兜底(错误面板一键重载防白屏)、文档四件套同步
This commit is contained in:
+6
-22
@@ -6,7 +6,7 @@ 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 { resolveScanArgs } from './scanIpc'
|
||||
import {
|
||||
parseScanCacheStore,
|
||||
putScanCache,
|
||||
@@ -313,28 +313,12 @@ ipcMain.handle(
|
||||
ignoreRules?: unknown
|
||||
) => {
|
||||
try {
|
||||
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即不沿用)
|
||||
const cache =
|
||||
Array.isArray(previous) && previous.every(
|
||||
(c) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
typeof (c as ScanCacheEntry).rel === 'string' &&
|
||||
typeof (c as ScanCacheEntry).status === 'string'
|
||||
)
|
||||
? (previous as ScanCacheEntry[])
|
||||
: undefined
|
||||
// 渲染端未携带内存缓存时(跨会话首次扫描),尝试注入 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
|
||||
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘)
|
||||
const args = resolveScanArgs(previous, ignoreRules, () =>
|
||||
getScanCache(loadScanCacheStore(), leftDir, rightDir)
|
||||
)
|
||||
const result = await scanFolders(leftDir, rightDir, {
|
||||
...(prev ? { previous: prev } : {}),
|
||||
...(ignore ? { ignore } : {}),
|
||||
...args,
|
||||
...(semantic
|
||||
? {
|
||||
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { ScanCacheEntry } from './folderScan'
|
||||
import { isScanCacheEntries, resolveScanArgs } from './scanIpc'
|
||||
|
||||
/** 构造一条合法缓存条目(IPC 形态校验只看 rel/status 字符串字段) */
|
||||
function entry(rel: string): ScanCacheEntry {
|
||||
return { rel, leftFp: { size: 1, mtime: 100 }, rightFp: { size: 1, mtime: 100 }, status: 'same', semSame: null }
|
||||
}
|
||||
|
||||
/** 盘上缓存读取 stub:返回指定缓存并记录调用 */
|
||||
function storedLoader(cache: ScanCacheEntry[] | null): (() => ScanCacheEntry[] | null) & { calls: number } {
|
||||
const f = (): ScanCacheEntry[] | null => {
|
||||
f.calls++
|
||||
return cache
|
||||
}
|
||||
f.calls = 0
|
||||
return f
|
||||
}
|
||||
|
||||
describe('isScanCacheEntries - IPC 形态校验', () => {
|
||||
it('合法缓存数组通过(rel/status 为字符串)', () => {
|
||||
expect(isScanCacheEntries([])).toBe(true)
|
||||
expect(isScanCacheEntries([entry('a.txt'), entry('b.txt')])).toBe(true)
|
||||
})
|
||||
|
||||
it('非数组 / 含非对象 / rel 或 status 非字符串拒绝', () => {
|
||||
expect(isScanCacheEntries(null)).toBe(false)
|
||||
expect(isScanCacheEntries(undefined)).toBe(false)
|
||||
expect(isScanCacheEntries('cache')).toBe(false)
|
||||
expect(isScanCacheEntries([entry('a.txt'), null])).toBe(false)
|
||||
expect(isScanCacheEntries([entry('a.txt'), 'garbage'])).toBe(false)
|
||||
expect(isScanCacheEntries([{ rel: 42, status: 'same' }])).toBe(false)
|
||||
expect(isScanCacheEntries([{ rel: 'a.txt', status: 1 }])).toBe(false)
|
||||
expect(isScanCacheEntries([{ rel: 'a.txt' }])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => {
|
||||
it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', () => {
|
||||
const stored = storedLoader([entry('disk.txt')])
|
||||
const mem = [entry('mem.txt')]
|
||||
const r = resolveScanArgs(mem, undefined, stored)
|
||||
expect(r.previous).toBe(mem)
|
||||
expect(stored.calls).toBe(0)
|
||||
})
|
||||
|
||||
it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', () => {
|
||||
// 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存
|
||||
const stored = storedLoader([entry('disk.txt')])
|
||||
const r = resolveScanArgs([], undefined, stored)
|
||||
expect(r.previous).toEqual([])
|
||||
expect(stored.calls).toBe(0)
|
||||
})
|
||||
|
||||
it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', () => {
|
||||
const disk = [entry('disk.txt')]
|
||||
for (const bad of [undefined, null, 'x', [{ rel: 1, status: 'same' }]] as unknown[]) {
|
||||
const stored = storedLoader(disk)
|
||||
const r = resolveScanArgs(bad, undefined, stored)
|
||||
expect(r.previous).toBe(disk)
|
||||
expect(stored.calls).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', () => {
|
||||
expect(resolveScanArgs(undefined, undefined, storedLoader(null)).previous).toBeUndefined()
|
||||
expect(resolveScanArgs(undefined, undefined, storedLoader([])).previous).toBeUndefined()
|
||||
})
|
||||
|
||||
it('忽略规则合法字符串数组传递(空数组合法)', () => {
|
||||
expect(resolveScanArgs(undefined, ['node_modules/', '*.log'], storedLoader(null)).ignore).toEqual([
|
||||
'node_modules/',
|
||||
'*.log'
|
||||
])
|
||||
expect(resolveScanArgs(undefined, [], storedLoader(null)).ignore).toEqual([])
|
||||
})
|
||||
|
||||
it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', () => {
|
||||
expect(resolveScanArgs(undefined, null, storedLoader(null)).ignore).toBeUndefined()
|
||||
expect(resolveScanArgs(undefined, 'node_modules/', storedLoader(null)).ignore).toBeUndefined()
|
||||
expect(resolveScanArgs(undefined, ['a', 42], storedLoader(null)).ignore).toBeUndefined()
|
||||
expect(
|
||||
resolveScanArgs(
|
||||
undefined,
|
||||
Array.from({ length: 51 }, () => 'a'),
|
||||
storedLoader(null)
|
||||
).ignore
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', () => {
|
||||
const disk = [entry('disk.txt')]
|
||||
const stored = storedLoader(disk)
|
||||
const r = resolveScanArgs([{ rel: 'bad' }], ['dist/'], stored)
|
||||
expect(r.previous).toBe(disk)
|
||||
expect(r.ignore).toEqual(['dist/'])
|
||||
expect(stored.calls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ScanCacheEntry } from './folderScan'
|
||||
import { isIgnoreRulesArray } from './ignoreRules'
|
||||
|
||||
/**
|
||||
* folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。
|
||||
* 渲染端经 IPC 传入的数据不可信(跨进程边界),逐字段校验后由主进程 handler 组装扫描参数;
|
||||
* 此前该逻辑内联在 index.ts(覆盖率排除),抽出后可被 vitest 直接测试。
|
||||
*/
|
||||
|
||||
/** previous(增量重扫缓存)是否为可接受的 IPC 形态:数组且每条含字符串 rel/status。
|
||||
* 只做形态级校验(渲染端数据来自上次扫描结果或损坏的偏好存储);字段级合法性
|
||||
* (指纹形态等)由 scanFolders 的指纹比对与 scanCacheStore 的解析校验兜底 */
|
||||
export function isScanCacheEntries(v: unknown): v is ScanCacheEntry[] {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.every(
|
||||
(c) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
typeof (c as ScanCacheEntry).rel === 'string' &&
|
||||
typeof (c as ScanCacheEntry).status === 'string'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export interface ResolvedScanArgs {
|
||||
/** 增量重扫缓存:渲染端携带的内存缓存优先(含空数组——同目录上次扫描的最新形态,
|
||||
* 阻止注入可能过期的盘上缓存);形态非法/未携带时回退盘上持久化缓存;两者皆无则缺省 */
|
||||
previous?: ScanCacheEntry[]
|
||||
/** 忽略规则:仅接受合法字符串数组形态(isIgnoreRulesArray 校验,空数组合法),非法即忽略 */
|
||||
ignore?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合 folder:scan 的入参校验与缓存源选择:
|
||||
* - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用;
|
||||
* - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入),
|
||||
* 惰性求值保证渲染端携带有效内存缓存时不产生读盘 IO;
|
||||
* - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。
|
||||
*/
|
||||
export function resolveScanArgs(
|
||||
previous: unknown,
|
||||
ignoreRules: unknown,
|
||||
loadStored: () => ScanCacheEntry[] | null
|
||||
): ResolvedScanArgs {
|
||||
const mem = isScanCacheEntries(previous) ? previous : undefined
|
||||
const fallback = (): ScanCacheEntry[] | undefined => {
|
||||
const stored = loadStored()
|
||||
return stored !== null && stored.length > 0 ? stored : undefined
|
||||
}
|
||||
const previousOut = mem !== undefined ? mem : fallback()
|
||||
return {
|
||||
...(previousOut !== undefined ? { previous: previousOut } : {}),
|
||||
...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {})
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,9 @@ export default function App(): ReactElement {
|
||||
? {
|
||||
snapshotNote: `修复 ${snapshotDiff.stats.fixed} · 新增差异 ${snapshotDiff.stats.regressed} · 新文件 ${snapshotDiff.stats.new} · 已移除 ${snapshotDiff.stats.gone} · 仍有差异 ${snapshotDiff.stats.changed}(基线 ${new Date(
|
||||
snapshot?.savedAt ?? Date.now()
|
||||
).toLocaleString('zh-CN')})`
|
||||
).toLocaleString('zh-CN')})`,
|
||||
// 对照模式导出:条目表替换为变迁对照表(含当前/基线状态与大小)
|
||||
snapshotEntries: snapshotDiff.entries
|
||||
}
|
||||
: {})
|
||||
},
|
||||
@@ -505,9 +507,14 @@ export default function App(): ReactElement {
|
||||
[folderState, folderSemantic, snapshotDiff, snapshot, showToast]
|
||||
)
|
||||
|
||||
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线) */
|
||||
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。
|
||||
* 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存 */
|
||||
const saveSnapshot = useCallback((): void => {
|
||||
if (!folderState) return
|
||||
if (folderState.truncated) {
|
||||
showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围')
|
||||
return
|
||||
}
|
||||
const snap: FolderSnapshot = {
|
||||
version: 1,
|
||||
leftDir: folderState.leftDir,
|
||||
|
||||
@@ -1892,6 +1892,24 @@ describe('App - 快照基线', () => {
|
||||
expect(snap!.entries.map((e) => e.rel)).toEqual(['a.txt', 'b.txt', 'c.txt'])
|
||||
})
|
||||
|
||||
it('扫描结果截断时保存快照被拦截提示,不落盘', async () => {
|
||||
const saveReport = vi.fn(async () => ({ ok: true, path: 'C:/docs/snap.json' }))
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: true, total: 9999 }),
|
||||
saveReport
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText(/文件过多/)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
|
||||
expect(await screen.findByText(/快照不完整无法对照/)).toBeInTheDocument()
|
||||
expect(saveReport).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/快照已保存/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('加载快照:对照模式渲染变迁统计与标注', async () => {
|
||||
await enterForSnapshot(makeSnapJson())
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
@@ -1935,6 +1953,10 @@ describe('App - 快照基线', () => {
|
||||
fireEvent.click(screen.getByText('纯文本'))
|
||||
await screen.findByText(/报告已保存/)
|
||||
expect(saved).toContain('快照对照:修复 0 · 新增差异 1 · 新文件 1 · 已移除 0 · 仍有差异 0')
|
||||
// 对照模式导出:条目表为变迁对照表(含变迁标签与当前/基线状态)
|
||||
expect(saved).toContain('新增差异')
|
||||
expect(saved).toContain('新文件')
|
||||
expect(saved).toContain('保持一致')
|
||||
})
|
||||
|
||||
it('退出对照恢复常规视图', async () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
|
||||
// React 抛渲染错误时向 console.error 输出组件栈噪音,测试中静音
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
/** 渲染期必抛错的子组件 */
|
||||
function Boom(): never {
|
||||
throw new Error('boom in render')
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
it('子组件渲染抛错时显示错误面板与重载按钮(不白屏)', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('界面渲染出现异常')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom in render')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新加载应用' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('子组件正常渲染时原样透传(零侵入)', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>ok-content</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('ok-content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('界面渲染出现异常')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层渲染错误兜底:任何子组件渲染期抛错时显示错误面板与一键重载,
|
||||
* 替代整窗白屏死机(用户至少能自助恢复)。
|
||||
* 捕获范围:渲染与生命周期阶段的错误(React ErrorBoundary 的固有边界);
|
||||
* 事件回调内的异常不经过此边界,由各回调自身的 try/catch 防御。
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
console.error('渲染错误(ErrorBoundary 捕获):', error)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h2>界面渲染出现异常</h2>
|
||||
<p className="error-message">{this.state.error.message || String(this.state.error)}</p>
|
||||
<button className="btn primary" onClick={() => window.location.reload()}>
|
||||
重新加载应用
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
|
||||
import { type ReportFormat } from '../diff/report'
|
||||
import { countFolderStats, fmtSize } from '../diff/folderReport'
|
||||
import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff'
|
||||
import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
|
||||
|
||||
@@ -91,14 +92,14 @@ const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType<typeof cou
|
||||
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
|
||||
]
|
||||
|
||||
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序) */
|
||||
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序;标签复用 snapshotDiff 的共享映射) */
|
||||
const SNAP_CHANGES: { key: SnapshotChange; text: string }[] = [
|
||||
{ key: 'fixed', text: '已修复' },
|
||||
{ key: 'regressed', text: '新增差异' },
|
||||
{ key: 'new', text: '新文件' },
|
||||
{ key: 'gone', text: '已移除' },
|
||||
{ key: 'changed', text: '仍有差异' },
|
||||
{ key: 'stable', text: '保持一致' }
|
||||
{ key: 'fixed', text: SNAPSHOT_CHANGE_LABEL.fixed },
|
||||
{ key: 'regressed', text: SNAPSHOT_CHANGE_LABEL.regressed },
|
||||
{ key: 'new', text: SNAPSHOT_CHANGE_LABEL.new },
|
||||
{ key: 'gone', text: SNAPSHOT_CHANGE_LABEL.gone },
|
||||
{ key: 'changed', text: SNAPSHOT_CHANGE_LABEL.changed },
|
||||
{ key: 'stable', text: SNAPSHOT_CHANGE_LABEL.stable }
|
||||
]
|
||||
|
||||
export default function FolderView({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
countFolderStats,
|
||||
plainFolderSummary
|
||||
} from './folderReport'
|
||||
import { diffSnapshot } from './snapshotDiff'
|
||||
|
||||
const ENTRIES: FolderEntry[] = [
|
||||
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
|
||||
@@ -180,3 +181,75 @@ describe('buildFolderReport - Markdown', () => {
|
||||
expect(md).toContain('两个文件夹内都没有文件')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFolderReport - 快照对照表(snapshotEntries)', () => {
|
||||
const SNAP: FolderEntry[] = [
|
||||
{ rel: 'fixed.txt', status: 'different', leftSize: 1, rightSize: 2 },
|
||||
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 },
|
||||
{ rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 }
|
||||
]
|
||||
const CUR: FolderEntry[] = [
|
||||
{ rel: 'fixed.txt', status: 'same', leftSize: 2, rightSize: 2 },
|
||||
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 5 },
|
||||
{ rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 }
|
||||
]
|
||||
const snapshotEntries = diffSnapshot(SNAP, CUR).entries
|
||||
const SNAP_CTX = {
|
||||
...CTX,
|
||||
snapshotNote: '修复 1 · 新增差异 0 · 新文件 1 · 已移除 1 · 仍有差异 0(基线 2026/1/1)',
|
||||
snapshotEntries
|
||||
}
|
||||
|
||||
it('HTML:表头切换为变迁对照列,行含变迁标签与当前/基线状态配色', () => {
|
||||
const html = buildFolderReport(CUR, SNAP_CTX, 'html')
|
||||
expect(html).toContain('<td>变迁</td>')
|
||||
expect(html).toContain('<td>当前状态</td>')
|
||||
expect(html).toContain('<td>基线状态</td>')
|
||||
// 行级:fixed(不同→相同)与 stable(相同→相同)
|
||||
expect(html).toContain('chg-fixed')
|
||||
expect(html).toContain('已修复')
|
||||
expect(html).toContain('chg-stable')
|
||||
expect(html).toContain('保持一致')
|
||||
// 普通条目表头不再出现
|
||||
expect(html).not.toContain('<td>状态</td>')
|
||||
})
|
||||
|
||||
it('HTML:gone 条目当前状态为 —,大小回退基线数据;new 条目基线状态为 —', () => {
|
||||
const html = buildFolderReport(CUR, SNAP_CTX, 'html')
|
||||
// 按表格行分块断言(tr 与单元格内容不在同一物理行)
|
||||
const trOf = (rel: string): string => html.split('<tr').find((c) => c.includes(rel)) ?? ''
|
||||
const goneRow = trOf('gone.txt')
|
||||
expect(goneRow).toContain('chg-gone')
|
||||
expect(goneRow).toContain('已移除')
|
||||
expect(goneRow).toContain('—')
|
||||
// gone 大小取基线(1 B / 2 B)
|
||||
expect(goneRow).toContain('1 B')
|
||||
expect(goneRow).toContain('2 B')
|
||||
const newRow = trOf('new.txt')
|
||||
expect(newRow).toContain('新文件')
|
||||
expect(newRow).toContain('—')
|
||||
})
|
||||
|
||||
it('纯文本:行含变迁标签与当前/基线状态,快照口径说明保留', () => {
|
||||
const txt = buildFolderReport(CUR, SNAP_CTX, 'txt')
|
||||
expect(txt).toContain('快照对照:修复 1')
|
||||
expect(txt).toContain('已修复 相同 不同')
|
||||
expect(txt.split('\n').some((l) => l.includes('gone.txt'))).toBe(true)
|
||||
// gone 行:当前状态为 —(基线仍为 不同)
|
||||
expect(txt.split('\n').some((l) => l.includes('已移除') && l.includes('—'))).toBe(true)
|
||||
})
|
||||
|
||||
it('Markdown:表格切换为变迁列结构', () => {
|
||||
const md = buildFolderReport(CUR, SNAP_CTX, 'md')
|
||||
expect(md).toContain('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |')
|
||||
expect(md).toContain('| 已修复 |')
|
||||
expect(md).toContain('| 已移除 |')
|
||||
expect(md).not.toContain('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |')
|
||||
})
|
||||
|
||||
it('不传 snapshotEntries 时保持普通条目表(回归锚)', () => {
|
||||
const html = buildFolderReport(ENTRIES, CTX, 'html')
|
||||
expect(html).toContain('<td>状态</td>')
|
||||
expect(html).not.toContain('<td>变迁</td>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
|
||||
import { esc, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report'
|
||||
import { SNAPSHOT_CHANGE_LABEL, type SnapshotDiffEntry } from './snapshotDiff'
|
||||
|
||||
/**
|
||||
* 文件夹对比报告生成器(HTML / 纯文本 / Markdown 三格式)。
|
||||
* 与单文件报告共用主题样式(REPORT_CSS)与转义工具;语义判等关闭时统计中语义同恒为 0。
|
||||
* 快照对照模式导出(snapshotEntries 提供)时条目表替换为变迁对照表(全量含 stable,
|
||||
* 与界面默认隐藏 stable 不同——报告口径为信息零丢失)。
|
||||
*/
|
||||
|
||||
export type { ReportFormat }
|
||||
@@ -20,6 +23,8 @@ export interface FolderReportContext {
|
||||
total: number
|
||||
/** 快照对照说明(快照对照模式导出时附注变迁统计与基线时间) */
|
||||
snapshotNote?: string
|
||||
/** 快照对照条目(对照模式导出时提供:条目表替换为变迁对照表) */
|
||||
snapshotEntries?: SnapshotDiffEntry[]
|
||||
}
|
||||
|
||||
/** 六类状态统计 */
|
||||
@@ -96,6 +101,7 @@ const FOLDER_EXTRA_CSS = `
|
||||
font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px;
|
||||
border:1px solid rgba(148,163,255,.14); border-radius:12px; overflow:hidden; }
|
||||
.f-table col.c-st { width:76px; }
|
||||
.f-table col.c-chg { width:88px; }
|
||||
.f-table col.c-ls { width:88px; }
|
||||
.f-table col.c-rs { width:88px; }
|
||||
.f-table thead td { font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:11px; color:#74809a;
|
||||
@@ -113,6 +119,12 @@ const FOLDER_EXTRA_CSS = `
|
||||
.f-table tr.st-left td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
|
||||
.f-table tr.st-right td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
|
||||
.f-table tr.st-unread td.st { color:#f6bd87; background:rgba(245,158,11,.10); }
|
||||
.f-table td.chg { text-align:center; font-family:'Segoe UI','Microsoft YaHei',sans-serif;
|
||||
font-weight:700; font-size:12px; border-radius:4px; }
|
||||
.f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#7ee2a8; background:rgba(34,197,94,.10); }
|
||||
.f-table tr.chg-regressed td.chg { color:#f7a9a9; background:rgba(248,113,113,.10); }
|
||||
.f-table tr.chg-changed td.chg { color:#f6bd87; background:rgba(245,158,11,.10); }
|
||||
.f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#74809a; background:rgba(148,163,255,.06); }
|
||||
.f-table tr:hover { background:rgba(34,211,238,.05); }
|
||||
.trunc-note { color:#f6bd87; font-size:12.5px; padding:8px 14px; border-radius:10px;
|
||||
background:rgba(245,158,11,.10); border:1px solid rgba(245,158,11,.35); margin-bottom:14px; }
|
||||
@@ -121,9 +133,28 @@ const FOLDER_EXTRA_CSS = `
|
||||
.f-table td { border-color:#eee; }
|
||||
.f-table thead td { background:#f3f3f6; color:#666; }
|
||||
.trunc-note { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
|
||||
.f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#15803d; background:#e9f9ef; }
|
||||
.f-table tr.chg-regressed td.chg { color:#b91c1c; background:#fdeeee; }
|
||||
.f-table tr.chg-changed td.chg { color:#b45309; background:#fdf4e5; }
|
||||
.f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#888; background:#f3f3f6; }
|
||||
}
|
||||
`
|
||||
|
||||
/** 变迁对照表的行 HTML(当前/基线状态缺失显示 —;大小取 current ?? baseline 与界面对照行一致) */
|
||||
function snapshotRowHtml(e: SnapshotDiffEntry): string {
|
||||
const display = e.current ?? e.baseline!
|
||||
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
|
||||
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
|
||||
return `<tr class="chg-${e.change}">
|
||||
<td class="chg">${SNAPSHOT_CHANGE_LABEL[e.change]}</td>
|
||||
<td class="rel">${esc(e.rel)}</td>
|
||||
<td class="st">${cur}</td>
|
||||
<td class="st">${base}</td>
|
||||
<td class="sz">${fmtSize(display.leftSize)}</td>
|
||||
<td class="sz">${fmtSize(display.rightSize)}</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
function buildFolderHtml(
|
||||
entries: FolderEntry[],
|
||||
ctx: FolderReportContext,
|
||||
@@ -133,30 +164,40 @@ function buildFolderHtml(
|
||||
const semLine = ctx.semantic
|
||||
? '判定口径:语义判等开启(空白/换行差异计为语义同,不计入不同)'
|
||||
: '判定口径:字节级严格比对(任何字节差异均计为不同)'
|
||||
// 对照模式:条目表替换为变迁对照表(全量含 stable),列结构随之切换
|
||||
const snaps = ctx.snapshotEntries
|
||||
const cols = snaps
|
||||
? '<colgroup><col class="c-chg"><col><col class="c-st"><col class="c-st"><col class="c-ls"><col class="c-rs"></colgroup>'
|
||||
: '<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>'
|
||||
const headRow = snaps
|
||||
? '<tr><td>变迁</td><td>相对路径</td><td>当前状态</td><td>基线状态</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
|
||||
: '<tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
|
||||
|
||||
const rowsHtml = entries
|
||||
.map((e) => {
|
||||
const cls =
|
||||
e.status === 'same'
|
||||
? 'st-same'
|
||||
: e.status === 'semantic-same'
|
||||
? 'st-sem'
|
||||
: e.status === 'different'
|
||||
? 'st-diff'
|
||||
: e.status === 'left-only'
|
||||
? 'st-left'
|
||||
: e.status === 'right-only'
|
||||
? 'st-right'
|
||||
: 'st-unread'
|
||||
const approx = e.approximate ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
|
||||
return `<tr class="${cls}">
|
||||
const rowsHtml = snaps
|
||||
? snaps.map(snapshotRowHtml).join('\n')
|
||||
: entries
|
||||
.map((e) => {
|
||||
const cls =
|
||||
e.status === 'same'
|
||||
? 'st-same'
|
||||
: e.status === 'semantic-same'
|
||||
? 'st-sem'
|
||||
: e.status === 'different'
|
||||
? 'st-diff'
|
||||
: e.status === 'left-only'
|
||||
? 'st-left'
|
||||
: e.status === 'right-only'
|
||||
? 'st-right'
|
||||
: 'st-unread'
|
||||
const approx = e.approximate ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
|
||||
return `<tr class="${cls}">
|
||||
<td class="st">${STATUS_LABEL[e.status]}</td>
|
||||
<td class="rel">${esc(e.rel)}${approx}</td>
|
||||
<td class="sz">${fmtSize(e.leftSize)}</td>
|
||||
<td class="sz">${fmtSize(e.rightSize)}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join('\n')
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -197,10 +238,18 @@ function buildFolderHtml(
|
||||
</header>
|
||||
${ctx.truncated ? `<div class="trunc-note">⚠ 文件数量超出上限(已发现 ${ctx.total}+ 个),以下仅包含部分条目,请人工确认完整性</div>` : ''}
|
||||
<table class="f-table">
|
||||
<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>
|
||||
<thead><tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr></thead>
|
||||
${cols}
|
||||
<thead>${headRow}</thead>
|
||||
<tbody>
|
||||
${entries.length === 0 ? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>' : rowsHtml}
|
||||
${
|
||||
snaps
|
||||
? snaps.length === 0
|
||||
? '<tr><td colspan="6" style="text-align:center;color:#74809a;padding:24px">快照与当前结果都没有条目</td></tr>'
|
||||
: rowsHtml
|
||||
: entries.length === 0
|
||||
? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>'
|
||||
: rowsHtml
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="foot">由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)</div>
|
||||
@@ -230,7 +279,24 @@ function buildFolderTxt(
|
||||
lines.push(`⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`)
|
||||
}
|
||||
lines.push('----------------------------------')
|
||||
if (entries.length === 0) {
|
||||
const snaps = ctx.snapshotEntries
|
||||
if (snaps) {
|
||||
// 变迁对照表:变迁 / 当前状态 / 基线状态 / 相对路径 / 大小(全量含 stable)
|
||||
if (snaps.length === 0) {
|
||||
lines.push('快照与当前结果都没有条目')
|
||||
} else {
|
||||
const width = Math.min(60, Math.max(...snaps.map((e) => e.rel.length)))
|
||||
for (const e of snaps) {
|
||||
const display = e.current ?? e.baseline!
|
||||
const rel = e.rel.length > width ? e.rel.slice(0, width - 1) + '…' : e.rel.padEnd(width)
|
||||
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
|
||||
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
|
||||
lines.push(
|
||||
`${SNAPSHOT_CHANGE_LABEL[e.change].padEnd(4)} ${cur.padEnd(3)} ${base.padEnd(3)} ${rel} ${fmtSize(display.leftSize).padStart(9)} ${fmtSize(display.rightSize).padStart(9)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (entries.length === 0) {
|
||||
lines.push('两个文件夹内都没有文件')
|
||||
} else {
|
||||
// 路径列宽对齐(封顶 60,超长路径截断保持可读)
|
||||
@@ -269,13 +335,30 @@ function buildFolderMd(
|
||||
if (ctx.truncated) {
|
||||
out.push(`> ⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`, '')
|
||||
}
|
||||
out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |')
|
||||
if (entries.length === 0) {
|
||||
out.push('| (空) | 两个文件夹内都没有文件 | — | — |')
|
||||
const snaps = ctx.snapshotEntries
|
||||
if (snaps) {
|
||||
// 变迁对照表(全量含 stable)
|
||||
out.push('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- | --- | --- |')
|
||||
if (snaps.length === 0) {
|
||||
out.push('| (空) | 快照与当前结果都没有条目 | — | — | — | — |')
|
||||
} else {
|
||||
for (const e of snaps) {
|
||||
const display = e.current ?? e.baseline!
|
||||
const rel = e.rel.replace(/\|/g, '\\|')
|
||||
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
|
||||
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
|
||||
out.push(`| ${SNAPSHOT_CHANGE_LABEL[e.change]} | \`${rel}\` | ${cur} | ${base} | ${fmtSize(display.leftSize)} | ${fmtSize(display.rightSize)} |`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const e of entries) {
|
||||
const rel = e.rel.replace(/\|/g, '\\|')
|
||||
out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`)
|
||||
out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |')
|
||||
if (entries.length === 0) {
|
||||
out.push('| (空) | 两个文件夹内都没有文件 | — | — |')
|
||||
} else {
|
||||
for (const e of entries) {
|
||||
const rel = e.rel.replace(/\|/g, '\\|')
|
||||
out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`)
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('', '> 生成于 DiffLens')
|
||||
|
||||
@@ -93,6 +93,16 @@ describe('isDiffStatus / snapshotDirsMatch', () => {
|
||||
expect(snapshotDirsMatch(snap, 'C:/other', 'D:/right')).toBe(false)
|
||||
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/other')).toBe(false)
|
||||
})
|
||||
|
||||
it('路径归一化后比较:盘符大小写/反斜杠/尾分隔符的同目录异写视为一致', () => {
|
||||
const snap = makeSnapshot()
|
||||
// Windows 大小写不敏感文件系统:C:\left 与 c:/left/ 为同一目录
|
||||
expect(snapshotDirsMatch(snap, 'c:/left/', 'd:\\right\\')).toBe(true)
|
||||
expect(snapshotDirsMatch(snap, 'C:\\left', 'D:\\right')).toBe(true)
|
||||
// 大小写敏感文件系统:路径段大小写不同仍是不同目录(不误判一致)
|
||||
expect(snapshotDirsMatch(snap, 'C:/Left', 'D:/right')).toBe(false)
|
||||
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/Right')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffSnapshot - 状态变迁对照', () => {
|
||||
|
||||
@@ -120,13 +120,35 @@ export interface SnapshotDiffResult {
|
||||
stats: SnapshotDiffStats
|
||||
}
|
||||
|
||||
/** 快照与当前扫描的目录对是否一致(不一致时对照无意义,调用方拦截) */
|
||||
/** 变迁类型的人话标签(FolderView 徽章与文件夹报告共用同一实现) */
|
||||
export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
|
||||
fixed: '已修复',
|
||||
regressed: '新增差异',
|
||||
new: '新文件',
|
||||
gone: '已移除',
|
||||
changed: '仍有差异',
|
||||
stable: '保持一致'
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一路径归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写(其余保留原大小写)。
|
||||
* 与主进程 scanCacheStore.ts 的 normDir 保持完全一致(两进程无法共享模块,改动需双向同步):
|
||||
* Windows 大小写不敏感文件系统下 C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判目录对不一致。
|
||||
*/
|
||||
function normDir(p: string): string {
|
||||
let s = p.replace(/\\/g, '/')
|
||||
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
|
||||
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
|
||||
return s
|
||||
}
|
||||
|
||||
/** 快照与当前扫描的目录对是否一致(路径归一化后比较;不一致时对照无意义,调用方拦截) */
|
||||
export function snapshotDirsMatch(
|
||||
baseline: FolderSnapshot,
|
||||
leftDir: string,
|
||||
rightDir: string
|
||||
): boolean {
|
||||
return baseline.leftDir === leftDir && baseline.rightDir === rightDir
|
||||
return normDir(baseline.leftDir) === normDir(leftDir) && normDir(baseline.rightDir) === normDir(rightDir)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1233,4 +1233,33 @@ button.fstat.active {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 顶层渲染错误兜底面板(ErrorBoundary) ============ */
|
||||
.error-boundary {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.error-boundary h2 {
|
||||
color: var(--mod-fg);
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.error-boundary .error-message {
|
||||
max-width: 70vw;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--del-bg);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
color: var(--del-fg);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
/* 错误信息需可复制上报(应用全局 user-select: none 的例外) */
|
||||
user-select: text;
|
||||
}
|
||||
Reference in New Issue
Block a user