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