feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)

This commit is contained in:
2026-08-18 14:40:44 +08:00
parent 2b43b5e676
commit be619c2377
24 changed files with 2030 additions and 151 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import iconv from 'iconv-lite'
import { decodeText, looksBinary } from './decode'
describe('looksBinary - 二进制启发式', () => {
it('空输入不判为二进制', () => {
expect(looksBinary(new Uint8Array(0))).toBe(false)
})
it('出现 NUL 字节立即判定二进制', () => {
const buf = new Uint8Array([0x61, 0x00, 0x62])
expect(looksBinary(buf)).toBe(true)
})
it('纯可见 ASCII 判为文本', () => {
const buf = new TextEncoder().encode('hello world text')
expect(looksBinary(buf)).toBe(false)
})
it('不可见控制字符占比超过 5% 判为二进制', () => {
// 20 字节中 2 字节为 0x0110%)> 5%;探测窗口默认 8KB,短输入全量统计
const buf = new Uint8Array([0x61, 0x61, 0x01, 0x01, ...Array(16).fill(0x61)])
expect(looksBinary(buf)).toBe(true)
})
it('制表符与换行等常见空白不计入可疑字符', () => {
const buf = new TextEncoder().encode('a\tb\nc\r\nd')
expect(looksBinary(buf)).toBe(false)
})
})
describe('decodeText - BOM 探测', () => {
it('UTF-8 BOM:剥离 BOM 解码并标注', () => {
const body = new TextEncoder().encode('中文内容')
const buf = new Uint8Array([0xef, 0xbb, 0xbf, ...body])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('中文内容')
expect(r.binary).toBe(false)
})
it('UTF-16 LE BOM:剥离 BOM 解码并标注', () => {
// UTF-16 LE'中' = 0x2D 0x4E 小端
const buf = new Uint8Array([0xff, 0xfe, 0x2d, 0x4e])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 LE')
expect(r.text).toBe('中')
})
it('UTF-16 BE BOMiconv 解码并标注', () => {
// '中' 的 UTF-16 BE = 0x4E 0x2D
const buf = new Uint8Array([0xfe, 0xff, 0x4e, 0x2d])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 BE')
expect(r.text).toBe('中')
})
it('仅 BOM 无内容:返回空文本', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb, 0xbf]))
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('')
})
it('短于 BOM 长度的输入走严格 UTF-8 路径', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb]))
// 0xEF 0xBB 不是合法 UTF-8 序列开头 → GBK 回退
expect(r.encoding).toBe('GBK')
})
})
describe('decodeText - 严格 UTF-8 与 GBK 回退', () => {
it('无 BOM 合法 UTF-8 中文:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('你好,世界'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('你好,世界')
expect(r.binary).toBe(false)
})
it('纯 ASCII:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('plain ascii'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('plain ascii')
})
it('GBK 编码中文:回退 GBK 解码正确', () => {
const r = decodeText(iconv.encode('中文GBK编码内容', 'gbk'))
expect(r.encoding).toBe('GBK')
expect(r.text).toBe('中文GBK编码内容')
expect(r.binary).toBe(false)
})
it('空输入:UTF-8 空文本', () => {
const r = decodeText(new Uint8Array(0))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('')
})
it('含 NUL 字节的非法 UTF-8:GBK 回退且判定二进制(NUL 启发式)', () => {
// 0xFF 破坏严格 UTF-8 触发 GBK 回退;0x00 命中 NUL 启发式判二进制
const buf = new Uint8Array([0x41, 0x00, 0xff, 0x43])
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
it('替换符占比超过 5% 判定二进制(乱码防护)', () => {
// 构造 GBK 解出大量替换符的字节:非法 GBK 序列密集
const buf = new Uint8Array(200).fill(0xff)
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
})
+66
View File
@@ -0,0 +1,66 @@
import iconv from 'iconv-lite'
/**
* 文本解码与编码探测的纯逻辑模块(无 electron 依赖,vitest 直接测试)。
* 入参类型为 Uint8Array:主进程同步路径传 Buffer(其子类),
* 解码 worker 路径经结构化克隆收到 Uint8Array,两侧共用同一实现。
*/
/** 解码结果:文本 + 探测到的编码标注 + 二进制启发式判定 */
export interface DecodeResult {
text: string
encoding: string
binary: boolean
}
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
export function looksBinary(buf: Uint8Array): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
// iconv-lite 的类型签名要求 Buffer,运行时仅依赖 Uint8Array 接口(length/下标/subarray),
// 结构化克隆产生的 Uint8Array 直接传入安全,此断言不产生拷贝
const asBuffer = (u: Uint8Array): Buffer => u as unknown as Buffer
/**
* 探测文本编码并解码:优先 BOMUTF-8 / UTF-16 BE / UTF-16 LE),
* 其次严格 UTF-8,失败则回退 GBK 解码。
*/
export function decodeText(buf: Uint8Array): DecodeResult {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: new TextDecoder().decode(buf.subarray(3)), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(asBuffer(buf.subarray(2)), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return {
text: new TextDecoder('utf-16le').decode(buf.subarray(2)),
encoding: 'UTF-16 LE',
binary: false
}
}
// 严格 UTF-8 探测
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
} catch {
const text = iconv.decode(asBuffer(buf), 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
}
}
+13
View File
@@ -0,0 +1,13 @@
import { parentPort } from 'worker_threads'
import { decodeText } from './decode'
/**
* 解码 worker 入口(worker_threads)。
* 协议与渲染端 diffWorker 同构:请求 { jobId, buffer } → 响应 { jobId, result }。
* 大文件解码(iconv-lite GBK 为纯 JS 实现)在此线程执行,不阻塞主进程事件循环。
* 注意:本文件随构建单独打包并 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程),
* iconv-lite 由构建配置打包进本文件(unpacked 路径下无法解析 asar 内 node_modules)。
*/
parentPort?.on('message', (msg: { jobId: number; buffer: Uint8Array }) => {
parentPort?.postMessage({ jobId: msg.jobId, result: decodeText(msg.buffer) })
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { scanFolders, type FolderEntry } from './folderScan'
/** 以临时目录为根构造两个待对比文件夹 */
let leftDir: string
let rightDir: string
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'difflens-scan-'))
leftDir = join(root, 'left')
rightDir = join(root, 'right')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
})
afterAll(async () => {
await rm(join(leftDir, '..'), { recursive: true, force: true })
})
async function put(dir: 'left' | 'right', rel: string, content: string | Buffer): Promise<void> {
const path = join(dir === 'left' ? leftDir : rightDir, rel)
const idx = rel.lastIndexOf('/')
if (idx >= 0) await mkdir(join(path, '..'), { recursive: true })
await writeFile(path, content)
}
const byRel = (entries: FolderEntry[], rel: string): FolderEntry | undefined =>
entries.find((e) => e.rel === rel)
describe('scanFolders - 状态判定', () => {
it('两侧内容一致判为 same,内容不同(同大小)判为 different,大小不同判为 different', async () => {
await put('left', 'same.txt', 'hello')
await put('right', 'same.txt', 'hello')
await put('left', 'content-diff.txt', 'abc')
await put('right', 'content-diff.txt', 'abd')
await put('left', 'size-diff.txt', 'short')
await put('right', 'size-diff.txt', 'much longer text')
const r = await scanFolders(leftDir, rightDir)
expect(r.truncated).toBe(false)
expect(byRel(r.entries, 'same.txt')).toMatchObject({ status: 'same', leftSize: 5, rightSize: 5 })
expect(byRel(r.entries, 'content-diff.txt')?.status).toBe('different')
expect(byRel(r.entries, 'size-diff.txt')).toMatchObject({ status: 'different' })
})
it('单侧缺失判为 only,大小字段另一侧为 null', async () => {
await put('left', 'only-left.txt', 'L')
await put('right', 'only-right.txt', 'R')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'only-left.txt')).toMatchObject({
status: 'left-only',
leftSize: 1,
rightSize: null
})
expect(byRel(r.entries, 'only-right.txt')).toMatchObject({
status: 'right-only',
leftSize: null,
rightSize: 1
})
})
it('递归子目录条目按相对路径对齐(统一 / 分隔)', async () => {
await put('left', 'src/core/a.ts', 'export const a = 1\n')
await put('right', 'src/core/a.ts', 'export const a = 2\n')
await put('right', 'src/util/b.ts', 'export const b = 1\n')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'src/core/a.ts')?.status).toBe('different')
expect(byRel(r.entries, 'src/util/b.ts')?.status).toBe('right-only')
})
it('二进制内容同样按字节判定(含 NUL 字节文件)', async () => {
await put('left', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x03]))
await put('right', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x04]))
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'bin.dat')?.status).toBe('different')
})
it('symlink 被跳过(防环且不参与判定)', async () => {
// 建一个指向已存在子目录的 symlink,不应出现在结果中也不应导致递归
await symlink(join(leftDir, 'src'), join(leftDir, 'src-link'), 'dir').catch(() => {})
const r = await scanFolders(leftDir, rightDir)
expect(r.entries.some((e) => e.rel.startsWith('src-link/'))).toBe(false)
})
it('条目按相对路径字典序排序', async () => {
const r = await scanFolders(leftDir, rightDir)
const rels = r.entries.map((e) => e.rel)
expect([...rels].sort()).toEqual(rels)
})
it('total 为两侧枚举文件数的较大值', async () => {
const r = await scanFolders(leftDir, rightDir)
const leftCount = r.entries.filter((e) => e.leftSize !== null).length
const rightCount = r.entries.filter((e) => e.rightSize !== null).length
expect(r.total).toBeGreaterThanOrEqual(Math.max(leftCount, rightCount))
})
})
describe('scanFolders - 边界与选项', () => {
it('两个空目录返回空结果', async () => {
const emptyL = await mkdtemp(join(tmpdir(), 'difflens-empty-l-'))
const emptyR = await mkdtemp(join(tmpdir(), 'difflens-empty-r-'))
try {
const r = await scanFolders(emptyL, emptyR)
expect(r.entries).toEqual([])
expect(r.total).toBe(0)
expect(r.truncated).toBe(false)
} finally {
await rm(emptyL, { recursive: true, force: true })
await rm(emptyR, { recursive: true, force: true })
}
})
it('目录不存在时抛出异常(由 IPC 层转为错误结果)', async () => {
await expect(scanFolders(join(leftDir, 'not-exist'), rightDir)).rejects.toThrow()
})
it('maxFiles 截断:条目不完整且 truncated 为 true', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-trunc-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-trunc-r-'))
try {
for (let i = 0; i < 5; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
const res = await scanFolders(l, r, { maxFiles: 2 })
expect(res.truncated).toBe(true)
expect(res.total).toBe(5)
expect(res.entries.length).toBeLessThanOrEqual(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('超过 maxContentBytes 的同大小文件:头部采样一致判 same 且带 approximate 标注', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-big-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-big-r-'))
try {
// 100KB 文件、maxContentBytes 压到 1KB:走采样路径;头部 8KB 一致 → same + approximate
const head = Buffer.alloc(8 * 1024, 0x41)
const tailL = Buffer.alloc(100 * 1024 - head.length, 0x01)
const tailR = Buffer.alloc(100 * 1024 - head.length, 0x02)
await writeFile(join(l, 'big.bin'), Buffer.concat([head, tailL]))
await writeFile(join(r, 'big.bin'), Buffer.concat([head, tailR]))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')).toMatchObject({ status: 'same', approximate: true })
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('采样头部不同直接判 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-head-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-head-r-'))
try {
await writeFile(join(l, 'big.bin'), Buffer.alloc(100 * 1024, 0x01))
await writeFile(join(r, 'big.bin'), Buffer.alloc(100 * 1024, 0x02))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
+131
View File
@@ -0,0 +1,131 @@
import { readdir, stat, readFile, open } from 'fs/promises'
import { join, relative, sep } from 'path'
/**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
*/
/** 单个文件条目的对比状态 */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
export interface FolderEntry {
/** 相对路径(统一 / 分隔,含子目录前缀) */
rel: string
status: FolderEntryStatus
/** 左侧文件字节数;该侧不存在为 null */
leftSize: number | null
/** 右侧文件字节数;该侧不存在为 null */
rightSize: number | null
/** 超过全量比对上限、仅采样头部判定的近似结果(status 为 same 时可能出现) */
approximate?: boolean
}
export interface ScanResult {
/** 全部条目(按相对路径字典序排序) */
entries: FolderEntry[]
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
truncated: boolean
/** 枚举发现的文件总数(截断时为上限值) */
total: number
}
export interface ScanOptions {
/** 单侧文件数量上限(默认 10000) */
maxFiles?: number
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
maxContentBytes?: number
}
export const FOLDER_MAX_FILES = 10000
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
/** 近似判定的采样头部字节数 */
const SAMPLE_BYTES = 8 * 1024
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
async function listFiles(
root: string,
maxFiles: number
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
const dirents = await readdir(root, { recursive: true, withFileTypes: true })
const rels: string[] = []
let total = 0
let truncated = false
for (const d of dirents) {
if (!d.isFile()) continue
total++
if (rels.length >= maxFiles) {
truncated = true
continue
}
// parentPath 为 Node 20.12+ 的 Dirent 属性(旧名 path
rels.push(relative(root, join(d.parentPath, d.name)).split(sep).join('/'))
}
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel))))
const files = new Map<string, number>()
rels.forEach((rel, i) => files.set(rel, stats[i].size))
return { files, truncated, total }
}
/** 读取文件头部指定字节数(近似判定采样) */
async function readHead(path: string, bytes: number): Promise<Buffer> {
const fh = await open(path, 'r')
try {
const buf = Buffer.alloc(bytes)
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
return buf.subarray(0, bytesRead)
} finally {
await fh.close()
}
}
/**
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小不同 → different
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
* 目录不存在/不可读时抛出异常,由 IPC 调用方转为错误结果。
*/
export async function scanFolders(
leftDir: string,
rightDir: string,
options: ScanOptions = {}
): Promise<ScanResult> {
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
const left = await listFiles(leftDir, maxFiles)
const right = await listFiles(rightDir, maxFiles)
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
const entries: FolderEntry[] = []
for (const rel of rels) {
const lSize = left.files.get(rel)
const rSize = right.files.get(rel)
if (lSize === undefined) {
entries.push({ rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null })
} else if (rSize === undefined) {
entries.push({ rel, status: 'left-only', leftSize: lSize, rightSize: null })
} else if (lSize !== rSize) {
entries.push({ rel, status: 'different', leftSize: lSize, rightSize: rSize })
} else if (lSize <= maxContentBytes) {
const lBuf = await readFile(join(leftDir, rel))
const rBuf = await readFile(join(rightDir, rel))
entries.push(
lBuf.equals(rBuf)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
)
} else {
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注
const lHead = await readHead(join(leftDir, rel), SAMPLE_BYTES)
const rHead = await readHead(join(rightDir, rel), SAMPLE_BYTES)
entries.push(
lHead.equals(rHead)
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
)
}
}
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
}
+112 -53
View File
@@ -1,9 +1,11 @@
import { join, dirname } from 'path'
import { join, dirname, sep } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron'
import { Worker } from 'worker_threads'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders } from './folderScan'
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
@@ -88,48 +90,88 @@ function createWindow(): void {
/** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
function looksBinary(buf: Buffer): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
/** 解码快路径阈值(256KB):小缓冲同步解码(毫秒级),大缓冲派发 worker 后台解码 */
const DECODE_FAST_PATH_BYTES = 256 * 1024
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding, binary }。
* 解码 workerworker_threads)单例管理。
* 大文件解码(GBK 纯 JS 解码可达数百毫秒)移入后台线程,避免阻塞主进程事件循环;
* 崩溃时拒绝在途任务并销毁实例(下次请求重建),调用方回退主进程同步解码。
*/
function decodeText(buf: Buffer): { text: string; encoding: string; binary: boolean } {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false }
}
// 严格 UTF-8 探测
let decodeWorker: Worker | null = null
let decodeJobSeq = 0
const pendingDecode = new Map<
number,
{ resolve: (r: DecodeResult) => void; reject: (e: Error) => void }
>()
/** 解码 worker 脚本路径:打包后位于 asar.unpackedElectron 的 asar 补丁不覆盖 worker 线程) */
function decodeWorkerPath(): string {
const p = join(__dirname, 'decodeWorker.js')
return p.includes(`app.asar${sep}`) ? p.replace(`app.asar${sep}`, `app.asar.unpacked${sep}`) : p
}
function getDecodeWorker(): Worker {
if (decodeWorker) return decodeWorker
const w = new Worker(decodeWorkerPath())
w.on('message', (msg: { jobId: number; result: DecodeResult }) => {
pendingDecode.get(msg.jobId)?.resolve(msg.result)
pendingDecode.delete(msg.jobId)
})
w.on('error', (err: Error) => {
for (const { reject } of pendingDecode.values()) reject(err)
pendingDecode.clear()
const dead = decodeWorker
decodeWorker = null
dead?.terminate().catch(() => {})
})
// 意外退出兜底:拒绝全部在途任务(error 分支已处理时此处为 no-op),下次请求重建
w.on('exit', () => {
for (const { reject } of pendingDecode.values()) reject(new Error('decode worker exited'))
pendingDecode.clear()
if (decodeWorker === w) decodeWorker = null
})
decodeWorker = w
return w
}
/** 大缓冲派发 worker 解码;worker 创建/运行失败时回退主进程同步解码(行为不降级) */
async function decodeMaybeWorker(buf: Buffer): Promise<DecodeResult> {
if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf)
const jobId = ++decodeJobSeq
return new Promise<DecodeResult>((resolve, reject) => {
let w: Worker
try {
w = getDecodeWorker()
} catch (e) {
reject(e instanceof Error ? e : new Error('decode worker unavailable'))
return
}
pendingDecode.set(jobId, { resolve, reject })
w.postMessage({ jobId, buffer: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) })
}).catch(() => decodeText(buf))
}
/** 按路径读取并解码文本文件(file:open 与 file:read-by-path 共用;10MB 上限) */
async function readTextFile(
filePath: string
): Promise<
| { path: string; name: string; text: string; encoding: string; binary: boolean }
| { error: 'too-large' | 'read-failed'; name: string; size?: number }
> {
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
const st = await fs.promises.stat(filePath)
if (st.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: st.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
const text = iconv.decode(buf, 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
return { error: 'read-failed', name }
}
const { text, encoding, binary } = await decodeMaybeWorker(buf)
return { path: filePath, name, text, encoding, binary }
}
/** IPC: 打开文件对话框并读取内容 */
@@ -151,24 +193,12 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const stat = await fs.promises.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed', name }
}
const { text, encoding, binary } = decodeText(buf)
return { path: filePath, name, text, encoding, binary }
return readTextFile(filePath)
})
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑,大缓冲走 worker */
ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
return decodeMaybeWorker(Buffer.from(buffer))
})
/** IPC: 保存差异报告(系统保存对话框 + 写入内容) */
@@ -200,6 +230,35 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
return true
})
/** IPC: 选择文件夹(文件夹对比入口;记忆上次目录) */
ipcMain.handle('folder:pick', async () => {
const result = await dialog.showOpenDialog({
title: '选择要对比的文件夹',
defaultPath: appState.lastDir,
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
const dirPath = result.filePaths[0]
appState.lastDir = dirPath
saveState()
return dirPath
})
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定,异步 fs 不阻塞主进程) */
ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string) => {
try {
return await scanFolders(leftDir, rightDir)
} catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const }
}
})
/** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */
ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
return readTextFile(filePath)
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens')
+31 -1
View File
@@ -22,6 +22,30 @@ export interface FileOpenError {
/** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */
export type FileOpenResult = FileData | FileOpenError | null
/** 文件夹对比条目状态(与主进程 folderScan 对齐) */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
/** 文件夹对比单个条目 */
export interface FolderEntry {
rel: string
status: FolderEntryStatus
leftSize: number | null
rightSize: number | null
approximate?: boolean
}
/** 文件夹扫描结果 */
export interface FolderScanResult {
entries: FolderEntry[]
truncated: boolean
total: number
}
/** 文件夹扫描失败(目录不存在/不可读) */
export interface FolderScanError {
error: 'scan-failed'
}
/** 主进程暴露给渲染进程的安全 API */
const api = {
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
@@ -33,7 +57,13 @@ const api = {
showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> =>
ipcRenderer.invoke('clipboard:write', text)
ipcRenderer.invoke('clipboard:write', text),
pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('folder:pick'),
scanFolder: (leftDir: string, rightDir: string): Promise<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke('folder:scan', leftDir, rightDir),
readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath)
}
export type DiffLensApi = typeof api
+228 -70
View File
@@ -18,6 +18,8 @@ import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff'
import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine'
import FolderView from './components/FolderView'
import type { FolderEntry, FolderScanResult } from '../../preload/index'
/** 生成默认报告文件名所需的时间戳:YYYYMMDD-HHmmss */
function stamp(): string {
@@ -154,6 +156,15 @@ function EmptyPane({
export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null)
// 工作模式:单文件对比 / 文件夹对比(双击文件夹条目临时切回 file,可返回)
const [mode, setMode] = useState<'file' | 'folder'>('file')
// 文件夹对比状态(扫描结果 + 两侧目录);从文件夹双击进入单文件对比时保留,供返回
const [folderState, setFolderState] = useState<
(FolderScanResult & { leftDir: string; rightDir: string }) | null
>(null)
const [folderScanning, setFolderScanning] = useState(false)
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件)
const [folderOnlyDiff, setFolderOnlyDiff] = useState(true)
// 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
const [initialPrefs] = useState(() => loadPrefs())
// 比较选项与视图开关:初始值从持久化偏好恢复
@@ -229,23 +240,19 @@ export default function App(): ReactElement {
[showToast]
)
const openPane = useCallback(
async (side: 'left' | 'right') => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
data = await window.api.openFile(side)
} catch {
showToast('文件读取失败,请重试')
return
}
if (!data) return
/** 将主进程文件读取结果应用到指定侧面板(openPane 与文件夹双击进入共用:错误提示/预警/装载) */
const applyFileData = useCallback(
(
side: 'left' | 'right',
data: NonNullable<Awaited<ReturnType<typeof window.api.readByPath>>>
): boolean => {
if ('error' in data) {
showToast(
data.error === 'too-large'
? `文件超过 10MB,暂不支持对比:${data.name ?? ''}`
: `文件读取失败:${data.name ?? ''}(可能被占用或权限不足)`
)
return
return false
}
if (data.binary) showToast(`疑似二进制文件,内容可能乱码:${data.name}`)
warnHeavyLines(data.text)
@@ -256,10 +263,26 @@ export default function App(): ReactElement {
}
if (side === 'left') setPaneL(pane)
else setPaneR(pane)
return true
},
[showToast, warnHeavyLines]
)
const openPane = useCallback(
async (side: 'left' | 'right') => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
data = await window.api.openFile(side)
} catch {
showToast('文件读取失败,请重试')
return
}
if (!data) return
applyFileData(side, data)
},
[showToast, applyFileData]
)
const dropPane = useCallback(
async (side: 'left' | 'right', files: File[]) => {
const file = files[0]
@@ -300,6 +323,91 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines]
)
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式) */
const runFolderScan = useCallback(
async (leftDir: string, rightDir: string): Promise<void> => {
setFolderScanning(true)
try {
const res = await window.api.scanFolder(leftDir, rightDir)
if ('error' in res) {
showToast('文件夹扫描失败,请检查目录是否存在且可访问')
return
}
setFolderState({ ...res, leftDir, rightDir })
setMode('folder')
} catch {
showToast('文件夹扫描失败,请重试')
} finally {
setFolderScanning(false)
}
},
[showToast]
)
/** 入口:连续选择左右两个文件夹后开始对比 */
const startFolderCompare = useCallback(async (): Promise<void> => {
let leftDir: string | null = null
let rightDir: string | null = null
try {
leftDir = await window.api.pickFolder()
if (!leftDir) return
rightDir = await window.api.pickFolder()
} catch {
showToast('选择文件夹失败,请重试')
return
}
if (!rightDir) return
await runFolderScan(leftDir, rightDir)
}, [showToast, runFolderScan])
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回) */
const openFolderEntry = useCallback(
async (entry: FolderEntry): Promise<void> => {
if (!folderState) return
const loadSide = async (side: 'left' | 'right', path: string | null): Promise<boolean> => {
// 条目单侧不存在:清空该侧面板(避免残留上一个条目的内容)
if (path === null) {
if (side === 'left') setPaneL(null)
else setPaneR(null)
return true
}
let data: Awaited<ReturnType<typeof window.api.readByPath>>
try {
data = await window.api.readByPath(path)
} catch {
showToast('文件读取失败,请重试')
return false
}
if (!data) return false
return applyFileData(side, data)
}
const okL = await loadSide(
'left',
entry.status === 'right-only' ? null : `${folderState.leftDir}/${entry.rel}`
)
const okR = await loadSide(
'right',
entry.status === 'left-only' ? null : `${folderState.rightDir}/${entry.rel}`
)
// 至少一侧装载成功才进入对比视图(双侧失败保持文件夹列表)
if (okL || okR) setMode('file')
},
[folderState, applyFileData, showToast]
)
/** 从单文件对比返回文件夹列表(清空两侧面板,文件夹扫描结果保留) */
const backToFolder = useCallback((): void => {
setPaneL(null)
setPaneR(null)
setMode('folder')
}, [])
/** 退出文件夹对比(清空文件夹状态,回到单文件空态) */
const exitFolder = useCallback((): void => {
setFolderState(null)
setMode('file')
}, [])
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
@@ -519,45 +627,77 @@ export default function App(): ReactElement {
</div>
</div>
<div className="header-spacer" />
<button className="btn ghost" onClick={() => void openPane('left')}>
</button>
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
<div className="tool-group export-wrap" ref={pasteWrapRef}>
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
</button>
{pasteOpen && (
<div className="export-menu">
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('left')
}}
>
{mode === 'file' && (
<>
{folderState && (
<button className="btn ghost" onClick={backToFolder} title="返回文件夹列表(扫描结果保留)">
</button>
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('right')
}}
>
</button>
</div>
)}
</div>
<button className="btn ghost" onClick={handleClear}>
</button>
)}
<button className="btn ghost" onClick={() => void openPane('left')}>
</button>
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
</>
)}
{mode === 'file' && (
<div className="tool-group export-wrap" ref={pasteWrapRef}>
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
</button>
{pasteOpen && (
<div className="export-menu">
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('left')
}}
>
</button>
<button
className="ctx-item"
onClick={() => {
setPasteOpen(false)
setPasteSide('right')
}}
>
</button>
</div>
)}
</div>
)}
{mode === 'file' && (
<>
<button className="btn ghost" onClick={handleClear}>
</button>
<button className="btn ghost" onClick={() => void startFolderCompare()} title="选择两个文件夹,对比全部文件差异">
</button>
</>
)}
</header>
{anyPane ? (
{mode === 'folder' && folderState ? (
<FolderView
leftDir={folderState.leftDir}
rightDir={folderState.rightDir}
entries={folderState.entries}
truncated={folderState.truncated}
total={folderState.total}
scanning={folderScanning}
onlyDiff={folderOnlyDiff}
onOnlyDiffChange={setFolderOnlyDiff}
onReopen={() => void startFolderCompare()}
onExit={exitFolder}
onOpenEntry={(e) => void openFolderEntry(e)}
/>
) : anyPane ? (
<>
<DiffView
items={displayRows}
@@ -594,31 +734,49 @@ export default function App(): ReactElement {
)}
<footer className="status-bar">
<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>
</span>
</div>
<div className="status-spacer" />
<div className="status-right">
{computing ? (
<span className="eq-badge busy"></span>
) : summary.changedLines === 0 ? (
<span className="eq-badge same"></span>
) : (
<span className="eq-badge diff"></span>
)}
</div>
{mode === 'folder' ? (
<>
<div className="status-left">
<span className="status-item"> · </span>
</div>
<div className="status-spacer" />
</>
) : (
<>
<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>
</span>
</div>
<div className="status-spacer" />
<div className="status-right">
{computing ? (
<span className="eq-badge busy"></span>
) : summary.changedLines === 0 ? (
<span className="eq-badge same"></span>
) : (
<span className="eq-badge diff"></span>
)}
</div>
</>
)}
</footer>
{folderScanning && mode !== 'folder' && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
</div>
)}
{computing && (
<div className="computing-overlay">
<span className="computing-ring" />
+133 -1
View File
@@ -18,7 +18,10 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }),
saveReport: async () => ({ ok: false, path: null }),
showInFolder: async () => true,
setClipboard: clipboardSpy
setClipboard: clipboardSpy,
pickFolder: async () => null,
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
readByPath: async () => null
}
return { ...base, ...overrides } as typeof window.api
}
@@ -960,4 +963,133 @@ describe('App - 偏好持久化(比较选项 + 仅看差异)', () => {
const saved2 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}')
expect(saved2.onlyDiff).toBe(true)
})
})
describe('App - 文件夹对比', () => {
const folderEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 4 },
{ rel: 'c.txt', status: 'left-only' as const, leftSize: 2, rightSize: null }
]
/** 进入文件夹对比模式的公共流程:依次选择左右目录并返回固定扫描结果 */
async function enterFolderMode() {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 })
})
const utils = render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('C:/left-dir')
return utils
}
it('依次选择两侧文件夹后进入文件夹模式:路径卡片、统计与状态栏', async () => {
await enterFolderMode()
expect(screen.getAllByText('D:/right-dir').length).toBeGreaterThan(0)
expect(screen.getByText('相同 1')).toBeInTheDocument()
expect(screen.getByText('不同 1')).toBeInTheDocument()
expect(screen.getByText('仅左 1')).toBeInTheDocument()
// 默认仅看差异:same 条目被过滤
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
// 状态栏切换为文件夹模式
expect(screen.getByText('文件夹对比 · 双击条目进入单文件对比')).toBeInTheDocument()
})
it('第一次选择取消不进入文件夹模式', async () => {
window.api = mockApi({ pickFolder: async () => null })
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
it('第二次选择取消不进入文件夹模式', async () => {
const dirs = ['C:/left-dir', null]
let call = 0
window.api = mockApi({ pickFolder: async () => dirs[call++] })
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
})
it('扫描失败给出提示且不进入文件夹模式', async () => {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ error: 'scan-failed' as const })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/文件夹扫描失败,请检查目录/)).toBeInTheDocument()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
it('双击差异条目加载两侧文件进入单文件对比,可返回文件夹', async () => {
window.api = mockApi({
pickFolder: async () => null,
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 }),
readByPath: async (p: string) => {
if (p === 'C:/left-dir/b.txt') {
return { path: p, name: 'b.txt', text: 'same\naaa', encoding: 'UTF-8', binary: false }
}
if (p === 'D:/right-dir/b.txt') {
return { path: p, name: 'b.txt', text: 'same\nbbb', encoding: 'UTF-8', binary: false }
}
return null
}
})
// 先进入文件夹模式(手动设置流程),再双击
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api.pickFolder = async () => dirs[call++] ?? null
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.dblClick(screen.getByText('b.txt'))
// 进入单文件对比视图:两侧内容渲染 + 差异统计(aaa/bbb 整词替换,词级高亮不拆分)
expect(await screen.findByText('aaa')).toBeInTheDocument()
expect(screen.getByText('bbb')).toBeInTheDocument()
expect(screen.getByText('有差异')).toBeInTheDocument()
// header 出现返回文件夹按钮
expect(screen.getByText('← 返回文件夹对比')).toBeInTheDocument()
// 返回文件夹:清空两侧面板,回到文件夹列表
fireEvent.click(screen.getByText('← 返回文件夹对比'))
expect(await screen.findByText('相同 1')).toBeInTheDocument()
expect(screen.queryByText('aaa')).not.toBeInTheDocument()
})
it('双击仅左侧条目:加载左侧并清空右侧', async () => {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: folderEntries, truncated: false, total: 3 }),
readByPath: async (p: string) =>
p === 'C:/left-dir/c.txt'
? { path: p, name: 'c.txt', text: 'only-c', encoding: 'UTF-8', binary: false }
: null
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('c.txt')
fireEvent.dblClick(screen.getByText('c.txt'))
expect(await screen.findByText('only-c')).toBeInTheDocument()
// 右侧面板为空槽(内容为 only-c 的删除行)
const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)
expect(paneFiles[0]).toBe('c.txt')
expect(paneFiles[1]).toBe('(未选择)')
})
it('返回文件对比后清空文件夹状态回到单文件空态', async () => {
await enterFolderMode()
fireEvent.click(screen.getByText('返回文件对比'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
// header 不再出现返回文件夹按钮(文件夹状态已清空)
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import FolderView, { FOLDER_ROW_HEIGHT } from './FolderView'
import type { FolderEntry } from '../../../preload/index'
const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
{ rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 },
{ rel: 'only-left.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 }
]
function renderView(entries: FolderEntry[] = ENTRIES, onlyDiff = true): ReturnType<typeof render> {
return render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={entries}
truncated={false}
total={entries.length}
scanning={false}
onlyDiff={onlyDiff}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
}
describe('FolderView', () => {
it('渲染两侧目录路径与操作按钮', () => {
renderView()
expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0)
expect(screen.getAllByText('D:/right').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: '重新选择' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument()
})
it('渲染统计徽章(相同/不同/仅左/仅右/总数)', () => {
renderView()
expect(screen.getByText('相同 1')).toBeInTheDocument()
expect(screen.getByText('不同 1')).toBeInTheDocument()
expect(screen.getByText('仅左 1')).toBeInTheDocument()
expect(screen.getByText('仅右 1')).toBeInTheDocument()
expect(screen.getByText('共 4 项')).toBeInTheDocument()
})
it('仅看差异开启时隐藏 same 条目,关闭后展示全部', () => {
const { rerender } = renderView()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
rerender(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByText('a.txt')).toBeInTheDocument()
})
it('双击条目触发 onOpenEntry(携带原始条目)', () => {
const onOpenEntry = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={onOpenEntry}
/>
)
fireEvent.dblClick(screen.getByText('b.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[1])
})
it('文件大小格式化(B/KB 与缺失占位)', () => {
renderView([...ENTRIES, { rel: 'big.bin', status: 'same', leftSize: 2048, rightSize: 2048 }], false)
expect(screen.getAllByText('2.0 KB').length).toBe(2)
expect(screen.getAllByText('—').length).toBeGreaterThan(0)
})
it('近似判定条目渲染 ≈ 标注', () => {
renderView([{ rel: 'huge.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }], false)
expect(screen.getByText('≈')).toBeInTheDocument()
})
it('截断时展示文件过多提示', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES.slice(0, 2)}
truncated={true}
total={9999}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
})
it('全部一致且开启仅看差异时给出空态提示', () => {
renderView([{ rel: 'x.txt', status: 'same', leftSize: 1, rightSize: 1 }])
expect(screen.getByText('没有差异文件(两侧内容全部一致)')).toBeInTheDocument()
})
it('空文件夹给出空态提示', () => {
renderView([])
expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument()
})
it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满', () => {
const many: FolderEntry[] = Array.from({ length: 1000 }, (_, i) => ({
rel: `f${i}.txt`,
status: 'different' as const,
leftSize: i,
rightSize: i + 1
}))
const { container } = renderView(many)
const rows = container.querySelectorAll('.folder-row')
expect(rows.length).toBeGreaterThan(0)
expect(rows.length).toBeLessThan(100)
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement
expect(sizer.style.height).toBe(`${1000 * FOLDER_ROW_HEIGHT}px`)
})
})
+188
View File
@@ -0,0 +1,188 @@
import {
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement
} from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
export const FOLDER_ROW_HEIGHT = 26
/** 上下各多渲染的缓冲行数 */
const OVERSCAN = 8
/** 视口高度兜底值(jsdom 无布局时使用) */
const FALLBACK_VIEWPORT = 600
export interface FolderViewProps {
leftDir: string
rightDir: string
entries: FolderEntry[]
truncated: boolean
total: number
scanning: boolean
/** 仅看差异(过滤 same 条目);文件夹模式默认开启 */
onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void
/** 重新选择两侧文件夹 */
onReopen: () => void
/** 返回文件对比模式(清空文件夹状态) */
onExit: () => void
/** 双击条目进入单文件对比 */
onOpenEntry: (entry: FolderEntry) => void
}
const STATUS_META: Record<FolderEntryStatus, { icon: string; label: string; cls: string }> = {
same: { icon: '●', label: '相同', cls: 'st-same' },
different: { icon: '●', label: '不同', cls: 'st-diff' },
'left-only': { icon: '◀', label: '仅左侧', cls: 'st-left' },
'right-only': { icon: '▶', label: '仅右侧', cls: 'st-right' }
}
/** 人类可读的文件大小 */
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,
entries,
truncated,
total,
scanning,
onlyDiff,
onOnlyDiffChange,
onReopen,
onExit,
onOpenEntry
}: FolderViewProps): ReactElement {
// 统计(条目集不变时不重算)
const stats = useMemo(() => {
let same = 0
let different = 0
let leftOnly = 0
let rightOnly = 0
for (const e of entries) {
if (e.status === 'same') same++
else if (e.status === 'different') different++
else if (e.status === 'left-only') leftOnly++
else rightOnly++
}
return { same, different, leftOnly, rightOnly }
}, [entries])
const displayEntries = useMemo(
() => (onlyDiff ? entries.filter((e) => e.status !== 'same') : entries),
[entries, onlyDiff]
)
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 })
const updateRange = (): void => {
const el = scrollRef.current
if (!el) return
const vh = el.clientHeight || FALLBACK_VIEWPORT
const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN)
const end = Math.min(
displayEntries.length,
Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN
)
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
}
useLayoutEffect(() => {
updateRange()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayEntries])
return (
<div className="folder-view">
{/* 两侧文件夹卡片 */}
<div className="folder-head">
<div className="folder-card left" title={leftDir}>
<span className="pane-dot left" />
<span className="folder-path">{leftDir}</span>
</div>
<div className="folder-card right" title={rightDir}>
<span className="pane-dot right" />
<span className="folder-path">{rightDir}</span>
</div>
<div className="folder-actions">
<button className="btn ghost" onClick={onReopen}>
</button>
<button className="btn ghost" onClick={onExit}>
</button>
</div>
</div>
{/* 工具行:统计 + 过滤 */}
<div className="folder-toolbar">
<div className="folder-stats">
<span className="fstat st-same"> {stats.same}</span>
<span className="fstat st-diff"> {stats.different}</span>
<span className="fstat st-left"> {stats.leftOnly}</span>
<span className="fstat st-right"> {stats.rightOnly}</span>
<span className="fstat muted"> {entries.length} </span>
</div>
<label className="switch" title="隐藏两侧内容一致的文件,专注差异">
<input
type="checkbox"
checked={onlyDiff}
onChange={(e) => onOnlyDiffChange(e.target.checked)}
/>
</label>
{truncated && (
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
{total} {entries.length}
</span>
)}
</div>
{/* 条目列表(虚拟滚动) */}
<div className="folder-scroll" ref={scrollRef} onScroll={updateRange}>
<div style={{ height: displayEntries.length * FOLDER_ROW_HEIGHT }}>
{displayEntries.slice(range.start, range.end).map((e) => {
const meta = STATUS_META[e.status]
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
title={`双击对比:${e.rel}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{e.rel}</span>
{e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span>
</div>
)
})}
</div>
{!scanning && displayEntries.length === 0 && (
<div className="folder-empty-note">
{entries.length === 0
? '两个文件夹内都没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div>
)}
</div>
{scanning && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
</div>
)}
</div>
)
}
+165
View File
@@ -791,6 +791,171 @@ button:disabled {
background: rgba(34, 211, 238, 0.12);
}
/* ============ 文件夹对比视图 ============ */
.folder-view {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.folder-head {
flex: 0 0 auto;
display: flex;
align-items: stretch;
gap: 10px;
padding: 10px 18px 6px;
}
.folder-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.folder-path {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.folder-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 6px 18px 8px;
}
.folder-stats {
display: flex;
gap: 10px;
}
.fstat {
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 3px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
white-space: nowrap;
}
.fstat.muted {
color: var(--muted);
font-weight: 500;
}
.fstat.warn {
color: var(--mod-fg);
background: var(--mod-bg);
border-color: rgba(245, 158, 11, 0.3);
font-weight: 500;
}
.folder-scroll {
flex: 1;
overflow: auto;
position: relative;
border-top: 1px solid var(--border);
}
.folder-row {
display: flex;
align-items: center;
gap: 10px;
height: 26px;
line-height: 26px;
font-family: var(--mono);
font-size: 12.5px;
padding: 0 18px;
cursor: default;
user-select: none;
}
.folder-row:hover {
background: var(--surface-hover);
}
.folder-st-icon {
flex: none;
width: 16px;
text-align: center;
font-size: 10px;
}
.folder-row.st-same .folder-st-icon {
color: var(--add-fg);
}
.folder-row.st-diff .folder-st-icon {
color: var(--del-fg);
}
.folder-row.st-diff {
background: var(--del-bg);
}
.folder-row.st-left .folder-st-icon {
color: var(--accent);
}
.folder-row.st-right .folder-st-icon {
color: var(--accent-2);
}
.folder-rel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-approx {
flex: none;
color: var(--mod-fg);
font-size: 13px;
cursor: help;
}
.folder-size {
flex: none;
width: 76px;
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.folder-empty-note {
padding: 40px 0;
text-align: center;
color: var(--muted);
font-size: 13px;
letter-spacing: 1px;
}
/* ============ 后台计算遮罩 ============ */
.computing-overlay {
position: absolute;