fix: 0.7.1 文件夹扫描竞态守卫与边角修复 - 扫描会话序号丢弃过期结果(并发重扫不互相覆盖、退出后迟到结果不拉回文件夹模式)、FolderView补resize监听(拉伸窗口即时补齐渲染窗口)、行号列不透明背景(真机证实横向滚动文字透过sticky半透明行号列叠印)、最大化关闭改存getNormalBounds(会话内调整的窗口位置不丢失)、行id生成统一与fmtSize收敛共用、文档与版本号同步
This commit is contained in:
@@ -110,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.7.0
|
||||
- 版本:0.7.1
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
```
|
||||
|
||||
+4
-3
@@ -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)
|
||||
- 启动冒烟:标题与双空态面板、空态无导出入口
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+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