feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)
This commit is contained in:
@@ -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 字节为 0x01(10%)> 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 BOM:iconv 解码并标注', () => {
|
||||
// '中' 的 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* 探测文本编码并解码:优先 BOM(UTF-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 }
|
||||
}
|
||||
}
|
||||
@@ -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 实现)在此线程执行,不阻塞主进程事件循环。
|
||||
* 注意:本文件随构建单独打包并 asarUnpack(Electron 的 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) })
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 }。
|
||||
* 解码 worker(worker_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.unpacked(Electron 的 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
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user