From 494b2ad6ea5043807d410fc9b3e84aff2593ae4c Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 18 Aug 2026 20:41:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=200.7.1=20=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E7=AB=9E=E6=80=81=E5=AE=88=E5=8D=AB=E4=B8=8E?= =?UTF-8?q?=E8=BE=B9=E8=A7=92=E4=BF=AE=E5=A4=8D=20-=20=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=BA=8F=E5=8F=B7=E4=B8=A2=E5=BC=83=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=BB=93=E6=9E=9C=EF=BC=88=E5=B9=B6=E5=8F=91=E9=87=8D?= =?UTF-8?q?=E6=89=AB=E4=B8=8D=E4=BA=92=E7=9B=B8=E8=A6=86=E7=9B=96=E3=80=81?= =?UTF-8?q?=E9=80=80=E5=87=BA=E5=90=8E=E8=BF=9F=E5=88=B0=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=B8=8D=E6=8B=89=E5=9B=9E=E6=96=87=E4=BB=B6=E5=A4=B9=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=89=E3=80=81FolderView=E8=A1=A5resize=E7=9B=91?= =?UTF-8?q?=E5=90=AC=EF=BC=88=E6=8B=89=E4=BC=B8=E7=AA=97=E5=8F=A3=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E8=A1=A5=E9=BD=90=E6=B8=B2=E6=9F=93=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=EF=BC=89=E3=80=81=E8=A1=8C=E5=8F=B7=E5=88=97=E4=B8=8D=E9=80=8F?= =?UTF-8?q?=E6=98=8E=E8=83=8C=E6=99=AF=EF=BC=88=E7=9C=9F=E6=9C=BA=E8=AF=81?= =?UTF-8?q?=E5=AE=9E=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E6=96=87=E5=AD=97?= =?UTF-8?q?=E9=80=8F=E8=BF=87sticky=E5=8D=8A=E9=80=8F=E6=98=8E=E8=A1=8C?= =?UTF-8?q?=E5=8F=B7=E5=88=97=E5=8F=A0=E5=8D=B0=EF=BC=89=E3=80=81=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E5=85=B3=E9=97=AD=E6=94=B9=E5=AD=98getNormal?= =?UTF-8?q?Bounds=EF=BC=88=E4=BC=9A=E8=AF=9D=E5=86=85=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E7=AA=97=E5=8F=A3=E4=BD=8D=E7=BD=AE=E4=B8=8D=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=EF=BC=89=E3=80=81=E8=A1=8Cid=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=8EfmtSize=E6=94=B6=E6=95=9B=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E3=80=81=E6=96=87=E6=A1=A3=E4=B8=8E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/应用开发与版本迭代规范.md | 5 +- docs/测试策略与方案.md | 7 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 7 +- src/renderer/src/App.tsx | 18 ++++- src/renderer/src/__tests__/App.test.tsx | 79 +++++++++++++++++++ .../src/components/FolderView.test.tsx | 20 +++++ src/renderer/src/components/FolderView.tsx | 20 ++--- src/renderer/src/diff/diffEngine.ts | 3 +- src/renderer/src/diff/folderReport.ts | 4 +- src/renderer/src/styles/global.css | 12 +-- 13 files changed, 152 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2e61b27..1df782b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.7.0 +- 版本:0.7.1 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index dc9c787..1cf086f 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.7.0`** +当前基线版本:**`0.7.1`** --- @@ -82,7 +82,7 @@ z = 补丁版本号(Patch) - 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致) 0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步) 0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”) -0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(当前): +0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(已发布): - 语义判等:字节级判“不同”的文本文件(≤2MB)二次判等——剔除全部空白(换行符 CRLF/LF、缩进、空行、排版重排)后一致计为“语义同”(大小写敏感,大小写差异仍为实质差异),消除换行符误报;解码复用解码 worker(大缓冲后台解码不阻塞),与文件打开共用同一管线;开关默认开启并持久化,切换即重扫;语义同不计入差异(仅看差异过滤),双击仍可查看明细 - 子目录树形展示:条目按路径聚合为目录树(folderTree 纯逻辑模块:建树/状态聚合/扁平化/过滤),目录行展示子树聚合状态(different > unreadable > only > 语义同 > 相同 优先级)与文件计数,点击折叠/展开(默认全展开);树形扁平化行数组保持虚拟滚动;过滤/搜索后无文件的目录自动剪枝 - 搜索与状态过滤:文件名搜索框(相对路径子串、不区分大小写,命中路径强制展开);统计徽章可点击作状态精确过滤(可多选,与“仅看差异”互斥联动) @@ -91,6 +91,7 @@ z = 补丁版本号(Patch) - 大目录单点失败降级:单文件读取失败(Windows 文件锁/Defender 占用)标记“无法读取”状态(琥珀警示徽章,默认显示),单目录不可读跳过——不再整体失败丢弃全部结果(万级目录真机冒烟验证:被锁的 electron asar 正确降级,其余 9999 项正常判定) - 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎) - 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持) +0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步 ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 821915b..fe0451d 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -76,11 +76,11 @@ src/renderer/src/ │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集) │ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索) -│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动) +│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动/resize 重算) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -134,7 +134,7 @@ src/renderer/src/ - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 -- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) +- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 @@ -171,6 +171,7 @@ src/renderer/src/ - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) - 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 +- 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加) ### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron) - 启动冒烟:标题与双空态面板、空态无导出入口 diff --git a/package-lock.json b/package-lock.json index 4cc4bad..9d5b347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.5.5", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.5.5", + "version": "0.7.0", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 634f9ba..02e29c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.7.0", + "version": "0.7.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 83b8f0d..255b7c0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9d0d6bf..c46aff0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 => { + 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) diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index ca7297a..f250f1f 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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 + } + return scan === 1 + ? { entries: E1, truncated: false, total: 1 } + : { entries: E3, truncated: false, total: 1 } + } + }) + render() + // 第一轮:进入文件夹模式 + 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 + } + }) + render() + 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 }, diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index 047a7ab..c660184 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.test.tsx @@ -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) + }) }) diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 08d9716..29bcded 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -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 { + 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 ( diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index ac3ca19..f4cd68a 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -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 }, diff --git a/src/renderer/src/diff/folderReport.ts b/src/renderer/src/diff/folderReport.ts index f253b8d..482c66d 100644 --- a/src/renderer/src/diff/folderReport.ts +++ b/src/renderer/src/diff/folderReport.ts @@ -78,8 +78,8 @@ const STATUS_LABEL: Record = { 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` diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index f96fff8..7c1a88a 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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);