fix: 0.7.1 文件夹扫描竞态守卫与边角修复 - 扫描会话序号丢弃过期结果(并发重扫不互相覆盖、退出后迟到结果不拉回文件夹模式)、FolderView补resize监听(拉伸窗口即时补齐渲染窗口)、行号列不透明背景(真机证实横向滚动文字透过sticky半透明行号列叠印)、最大化关闭改存getNormalBounds(会话内调整的窗口位置不丢失)、行id生成统一与fmtSize收敛共用、文档与版本号同步

This commit is contained in:
2026-08-18 20:41:45 +08:00
parent 1c52f5d3cb
commit 494b2ad6ea
13 changed files with 152 additions and 31 deletions
+4 -3
View File
@@ -76,10 +76,11 @@ function createWindow(): void {
}
})
// 关闭时记忆窗口普通状态 bounds最大化/全屏/最小化时不记录,避免把超屏尺寸存进偏好)
// 关闭时记忆窗口普通状态 bounds最大化/全屏时 getNormalBounds 仍返回普通状态的
// 尺寸位置(本次会话调整过的窗口位置不因最大化关闭而丢失);最小化时不记录
mainWindow.on('close', () => {
if (mainWindow.isMinimized() || mainWindow.isMaximized() || mainWindow.isFullScreen()) return
const b = mainWindow.getBounds()
if (mainWindow.isMinimized()) return
const b = mainWindow.getNormalBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveState()
})
+17 -1
View File
@@ -353,12 +353,22 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines]
)
/**
* 文件夹扫描会话序号:并发扫描(语义切换触发重扫 / 重新选择新目录)按序号丢弃过期结果,
* 慢的旧扫描后完成不得覆盖新扫描的目录与条目、不得误弹失败提示;
* 退出文件夹对比时递增以作废在途扫描(迟到的结果不得把界面拉回文件夹模式)。
*/
const scanSeqRef = useRef(0)
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发) */
const runFolderScan = useCallback(
async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
const seq = ++scanSeqRef.current
setFolderScanning(true)
try {
const res = await window.api.scanFolder(leftDir, rightDir, semantic)
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
if (seq !== scanSeqRef.current) return
if ('error' in res) {
showToast('文件夹扫描失败,请检查目录是否存在且可访问')
return
@@ -366,9 +376,11 @@ export default function App(): ReactElement {
setFolderState({ ...res, leftDir, rightDir })
setMode('folder')
} catch {
if (seq !== scanSeqRef.current) return
showToast('文件夹扫描失败,请重试')
} finally {
setFolderScanning(false)
// 仅最新会话允许复位扫描态(新扫描进行中不被旧扫描的 finally 误清)
if (seq === scanSeqRef.current) setFolderScanning(false)
}
},
[showToast]
@@ -517,6 +529,10 @@ export default function App(): ReactElement {
/** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => {
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
// 也不得让扫描遮罩残留到下一次文件夹会话
scanSeqRef.current++
setFolderScanning(false)
setFolderState(null)
setPaneL(null)
setPaneR(null)
+79
View File
@@ -1206,6 +1206,85 @@ describe('App - 文件夹对比', () => {
})
})
describe('App - 文件夹扫描竞态', () => {
it('慢的旧扫描后完成不覆盖新扫描结果(重新选择抢先完成)', async () => {
const E1 = [{ rel: 'diff1.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E2 = [{ rel: 'stale.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E3 = [{ rel: 'fresh.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const dirs = ['C:/l1', 'D:/r1', 'C:/l2', 'D:/r2']
let pick = 0
let scan = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSlow: (v: any) => void = () => {}
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scan++
if (scan === 2) {
// 第二次扫描(语义切换触发)挂起,模拟大目录慢扫描
return new Promise((res) => {
resolveSlow = res
}) as ReturnType<typeof window.api.scanFolder>
}
return scan === 1
? { entries: E1, truncated: false, total: 1 }
: { entries: E3, truncated: false, total: 1 }
}
})
render(<App />)
// 第一轮:进入文件夹模式
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('diff1.txt')
// 语义切换触发慢扫描(在途),随后重新选择触发新目录的快扫描
fireEvent.click(screen.getByLabelText('语义判等'))
fireEvent.click(screen.getByText('重新选择'))
await screen.findByText('fresh.txt')
// 慢扫描此刻才完成:过期结果必须被丢弃(不覆盖新目录与条目)
await act(async () => {
resolveSlow({ entries: E2, truncated: false, total: 1 })
})
expect(screen.queryByText('stale.txt')).not.toBeInTheDocument()
expect(screen.getByText('fresh.txt')).toBeInTheDocument()
expect(screen.getAllByText('C:/l2').length).toBeGreaterThan(0)
// 扫描态不被旧扫描干扰(无残留遮罩)
expect(screen.queryByText(/正在扫描文件夹/)).not.toBeInTheDocument()
})
it('扫描进行中退出文件夹对比:迟到的扫描结果不把界面拉回文件夹模式', async () => {
const E1 = [{ rel: 'diff1.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const E2 = [{ rel: 'stale.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }]
const dirs = ['C:/l1', 'D:/r1']
let pick = 0
let scan = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSlow: (v: any) => void = () => {}
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scan++
if (scan === 1) return { entries: E1, truncated: false, total: 1 }
return new Promise((res) => {
resolveSlow = res
}) as ReturnType<typeof window.api.scanFolder>
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('diff1.txt')
// 语义切换触发慢扫描(在途),随即退出文件夹对比
fireEvent.click(screen.getByLabelText('语义判等'))
fireEvent.click(screen.getByText('返回文件对比'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
// 迟到的扫描完成:不得把界面拉回文件夹模式,也不得残留扫描遮罩
await act(async () => {
resolveSlow({ entries: E2, truncated: false, total: 1 })
})
expect(screen.getByText(/选择左侧文件/)).toBeInTheDocument()
expect(screen.queryByText('stale.txt')).not.toBeInTheDocument()
expect(screen.queryByText(/正在扫描文件夹/)).not.toBeInTheDocument()
})
})
describe('App - 文件夹语义判等', () => {
const semEntries = [
{ rel: 'same.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
@@ -399,4 +399,24 @@ describe('FolderView - 其他', () => {
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement
expect(sizer.style.height).toBe(`${1500 * FOLDER_ROW_HEIGHT}px`)
})
it('窗口尺寸变化后按新视口高度补齐渲染窗口(resize 重算)', () => {
// 200 个根层文件(无目录,扁平行);jsdom 无布局走 600px 兜底视口
const many: FolderEntry[] = Array.from({ length: 200 }, (_, i) => ({
rel: `f${i}.txt`,
status: 'different' as const,
leftSize: 1,
rightSize: 2
}))
const { container } = renderView(many, false)
const scroll = container.querySelector('.folder-scroll') as HTMLElement
expect(scroll).not.toBeNull()
const count = (): number => container.querySelectorAll('.folder-row').length
const before = count()
expect(before).toBeGreaterThan(0)
// stub clientHeight 模拟窗口拉高(600 → 1400):resize 后渲染窗口必须扩大
Object.defineProperty(scroll, 'clientHeight', { get: () => 1400, configurable: true })
fireEvent(window, new Event('resize'))
expect(count()).toBeGreaterThan(before + 20)
})
})
+11 -9
View File
@@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -8,7 +9,7 @@ import {
} from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { type ReportFormat } from '../diff/report'
import { countFolderStats } from '../diff/folderReport'
import { countFolderStats, fmtSize } from '../diff/folderReport'
import { useDismiss } from '../hooks/useDismiss'
import { buildFolderTree, filterEntries, flattenFolderTree } from './folderTree'
@@ -70,14 +71,6 @@ const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType<typeof cou
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
]
/** 人类可读的文件大小 */
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,
@@ -171,6 +164,15 @@ export default function FolderView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
// 窗口尺寸变化时按新视口高度补齐渲染窗口(与 DiffView 同思路;
// 缺失该监听时拉伸窗口变高后底部出现空白区,滚动一下才恢复)
useEffect(() => {
const onResize = (): void => updateRange()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
const q = query.trim()
return (
+1 -2
View File
@@ -145,7 +145,6 @@ function computeLineDiff(
rightText: string,
options: DiffOptions = {}
): DiffResult {
let rowSeq = 0
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)
@@ -180,7 +179,7 @@ function computeLineDiff(
const flushRemoved = (): void => {
for (const r of queuedRemoved) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: r.lineNo, text: r.text, segs: null },
+2 -2
View File
@@ -78,8 +78,8 @@ const STATUS_LABEL: Record<FolderEntryStatus, string> = {
unreadable: '无法读取'
}
/** 人类可读的文件大小 */
function fmtSize(n: number | null): string {
/** 人类可读的文件大小(报告与 FolderView 列表共用同一实现) */
export 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`
+7 -5
View File
@@ -305,12 +305,14 @@ button:disabled {
background: rgba(0, 0, 0, 0.12);
}
/* 行号列 sticky 悬浮于内容之上:背景必须不透明,否则横向滚动时行文字会
透过半透明行号列叠印(等效合成值 = 原半透明叠加预先合成到面板底色 #0a0e17) */
.ln {
flex: 0 0 46px;
text-align: right;
padding-right: 8px;
color: #4d5a74;
background: rgba(124, 108, 255, 0.05);
background: #101323;
border-right: 1px solid var(--border);
position: sticky;
left: 0;
@@ -332,7 +334,7 @@ button:disabled {
}
.diff-row.modified .ln {
color: var(--mod-fg);
background: rgba(245, 158, 11, 0.16);
background: #453314;
border-right-color: rgba(245, 158, 11, 0.3);
}
.diff-row.added {
@@ -340,7 +342,7 @@ button:disabled {
}
.diff-row.added .ln {
color: var(--add-fg);
background: rgba(34, 197, 94, 0.14);
background: #103c29;
border-right-color: rgba(34, 197, 94, 0.3);
}
.diff-row.removed {
@@ -348,7 +350,7 @@ button:disabled {
}
.diff-row.removed .ln {
color: var(--del-fg);
background: rgba(248, 113, 113, 0.13);
background: #42252c;
border-right-color: rgba(248, 113, 113, 0.3);
}
@@ -384,7 +386,7 @@ button:disabled {
background: rgba(148, 163, 255, 0.04);
}
.diff-row.fold .ln {
background: rgba(148, 163, 255, 0.03);
background: #141927;
}
.fold-note {
color: var(--muted);