fix: 双击文件夹条目读取失败清空该侧面板修复名不符实对比残留、枚举改手写BFS凑满即停修复超大目录枚举耗时(0.6.2)
This commit is contained in:
@@ -136,6 +136,32 @@ describe('scanFolders - 边界与选项', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('截断后不再深入子目录(凑满即停,total 为已遍历下限)', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-stop-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-stop-r-'))
|
||||
try {
|
||||
// 根目录 3 个文件 + 子目录 1 个文件,maxFiles=2:凑满后子目录不被遍历
|
||||
// (readdir 目录内条目顺序不保证,但 3 个根文件计入 total 与顺序无关)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writeFile(join(l, `f${i}.txt`), String(i))
|
||||
await writeFile(join(r, `f${i}.txt`), String(i))
|
||||
}
|
||||
await mkdir(join(l, 'sub'), { recursive: true })
|
||||
await mkdir(join(r, 'sub'), { recursive: true })
|
||||
await writeFile(join(l, 'sub', 'inner.txt'), 'x')
|
||||
await writeFile(join(r, 'sub', 'inner.txt'), 'x')
|
||||
const res = await scanFolders(l, r, { maxFiles: 2 })
|
||||
expect(res.truncated).toBe(true)
|
||||
// 根目录 3 文件全部计入(当前目录统计完整),sub 未被深入
|
||||
expect(res.total).toBe(3)
|
||||
expect(res.entries.some((e) => e.rel.startsWith('sub/'))).toBe(false)
|
||||
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-'))
|
||||
|
||||
+26
-12
@@ -1,5 +1,5 @@
|
||||
import { readdir, stat, readFile, open } from 'fs/promises'
|
||||
import { join, relative, sep } from 'path'
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
|
||||
@@ -27,7 +27,7 @@ export interface ScanResult {
|
||||
entries: FolderEntry[]
|
||||
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
|
||||
truncated: boolean
|
||||
/** 枚举发现的文件总数(含被截断未比对的文件,始终为真实总数) */
|
||||
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -50,24 +50,38 @@ const SAMPLE_BYTES = 8 * 1024
|
||||
*/
|
||||
export const SCAN_CONCURRENCY = 16
|
||||
|
||||
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
|
||||
/**
|
||||
* 手写 BFS 遍历目录树收集普通文件(相对路径统一 / 分隔,跳过 symlink 防环),
|
||||
* 凑满 maxFiles 即停止深入:readdir({recursive:true}) 只能枚举完整棵树、无法提前终止,
|
||||
* 误选超大目录(如含 node_modules)时枚举本身耗时数秒。
|
||||
* 截断后 total 为已遍历部分的文件数下限(剩余子树不再统计)。
|
||||
*/
|
||||
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
|
||||
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
|
||||
while (queue.length > 0) {
|
||||
const { dir, prefix } = queue.shift()!
|
||||
const dirents = await readdir(dir, { withFileTypes: true })
|
||||
for (const d of dirents) {
|
||||
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
|
||||
if (d.isDirectory()) {
|
||||
queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` })
|
||||
} else if (d.isFile()) {
|
||||
total++
|
||||
if (rels.length >= maxFiles) {
|
||||
truncated = true
|
||||
} else {
|
||||
rels.push(prefix === '' ? d.name : `${prefix}/${d.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// parentPath 为 Node 20.12+ 的 Dirent 属性(旧名 path)
|
||||
rels.push(relative(root, join(d.parentPath, d.name)).split(sep).join('/'))
|
||||
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
|
||||
if (truncated) break
|
||||
}
|
||||
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
|
||||
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel))))
|
||||
|
||||
@@ -369,11 +369,16 @@ export default function App(): ReactElement {
|
||||
const openFolderEntry = useCallback(
|
||||
async (entry: FolderEntry): Promise<void> => {
|
||||
if (!folderState) return
|
||||
// 清空指定侧面板:双击新条目的任一读取失败路径都必须清掉旧内容,
|
||||
// 否则成功侧与旧内容组成“名不符实”的对比(面板头还是旧文件名)
|
||||
const clearSide = (side: 'left' | 'right'): void => {
|
||||
if (side === 'left') setPaneL(null)
|
||||
else setPaneR(null)
|
||||
}
|
||||
const loadSide = async (side: 'left' | 'right', path: string | null): Promise<boolean> => {
|
||||
// 条目单侧不存在:清空该侧面板(避免残留上一个条目的内容)
|
||||
if (path === null) {
|
||||
if (side === 'left') setPaneL(null)
|
||||
else setPaneR(null)
|
||||
clearSide(side)
|
||||
return true
|
||||
}
|
||||
let data: Awaited<ReturnType<typeof window.api.readByPath>>
|
||||
@@ -381,10 +386,16 @@ export default function App(): ReactElement {
|
||||
data = await window.api.readByPath(path)
|
||||
} catch {
|
||||
showToast('文件读取失败,请重试')
|
||||
clearSide(side)
|
||||
return false
|
||||
}
|
||||
if (!data) return false
|
||||
return applyFileData(side, data)
|
||||
if (!data) {
|
||||
clearSide(side)
|
||||
return false
|
||||
}
|
||||
const ok = applyFileData(side, data)
|
||||
if (!ok) clearSide(side)
|
||||
return ok
|
||||
}
|
||||
const okL = await loadSide(
|
||||
'left',
|
||||
|
||||
@@ -1127,4 +1127,57 @@ describe('App - 文件夹对比', () => {
|
||||
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('双击条目一侧读取失败:失败侧清空显示未选择,不残留旧对比内容', async () => {
|
||||
// 场景:先在 file 模式加载左侧旧文件 → 进文件夹模式 → 双击条目,
|
||||
// 右侧 readByPath 返回读取失败 → 右侧必须清空(不能残留左侧旧对比的右侧内容)
|
||||
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/b.txt'
|
||||
? { path: p, name: 'b.txt', text: 'fresh-left', encoding: 'UTF-8', binary: false }
|
||||
: { error: 'read-failed' as const, name: 'b.txt' }
|
||||
})
|
||||
render(<App />)
|
||||
// 先加载两侧旧文件(默认 mock 两侧均为 a.txt / line1 line2,制造潜在残留源)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByText('打开右侧'))
|
||||
// 双侧加载完成(相同内容 → 完全一致徽章出现)
|
||||
await screen.findByText('两文件内容完全一致')
|
||||
// 进入文件夹模式(此路径下旧面板内容未清),双击 b.txt:左侧成功、右侧读取失败
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('b.txt')
|
||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||
// 左侧为新条目内容,右侧清空为未选择(不残留旧对比内容)
|
||||
expect(await screen.findByText('fresh-left')).toBeInTheDocument()
|
||||
const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)
|
||||
expect(paneFiles[0]).toBe('b.txt')
|
||||
expect(paneFiles[1]).toBe('(未选择)')
|
||||
expect(screen.queryByText('line2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('双击条目一侧 IPC 异常:同样清空失败侧并给出提示', 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) => {
|
||||
if (p === 'D:/right-dir/b.txt') throw new Error('ipc boom')
|
||||
return { path: p, name: 'b.txt', text: 'left-ok', encoding: 'UTF-8', binary: false }
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('b.txt')
|
||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||
expect(await screen.findByText(/文件读取失败,请重试/)).toBeInTheDocument()
|
||||
const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)
|
||||
expect(paneFiles[0]).toBe('b.txt')
|
||||
expect(paneFiles[1]).toBe('(未选择)')
|
||||
})
|
||||
})
|
||||
@@ -142,7 +142,7 @@ export default function FolderView({
|
||||
</label>
|
||||
{truncated && (
|
||||
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
|
||||
⚠ 文件过多({total}),仅展示前 {entries.length} 项
|
||||
⚠ 文件过多({total}+),仅展示前 {entries.length} 项
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user