fix: 0.9.1 快照收尾修复与稳健性增强 - 快照目录对归一化匹配(Windows同目录异写不误拦、与scanCacheStore同款逻辑双向同步)、截断结果保存快照拦截提示(防对照新文件误报)、folder:scan IPC校验抽scanIpc纯逻辑模块单测全覆盖(缓存源选择惰性读盘)、对照模式导出报告附变迁对照表(三格式当前/基线状态与大小、标签收敛SNAPSHOT_CHANGE_LABEL共享映射、E2E对照导出写盘断言)、顶层ErrorBoundary渲染兜底(错误面板一键重载防白屏)、文档四件套同步

This commit is contained in:
2026-08-19 09:20:28 +08:00
parent 35fa81df9d
commit 38512d7882
19 changed files with 571 additions and 73 deletions
+6 -22
View File
@@ -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(大缓冲后台解码,与文件打开共用同一管线)
+99
View File
@@ -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)
})
})
+56
View File
@@ -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 } : {})
}
}
+9 -2
View File
@@ -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,
+22
View File
@@ -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
}
}
+8 -7
View File
@@ -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>')
})
})
+111 -28
View File
@@ -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 - 状态变迁对照', () => {
+24 -2
View File
@@ -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)
}
/**
+4 -1
View File
@@ -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>
)
+29
View File
@@ -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;
}