diff --git a/README.md b/README.md index 8c86fb3..f156deb 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,17 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 -- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,也可直接点击定位 +- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 +- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 +- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 +- **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 -- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景 +- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 -- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出滚动卡顿预警 +- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 -- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 +- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行),便于分享与归档 - **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 - **滚动联动**:左右两个面板滚动位置自动同步 @@ -90,7 +93,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.2.6 +- 版本:0.3.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 79b6596..52db50c 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.2.6`** +当前基线版本:**`0.3.0`** --- @@ -62,8 +62,9 @@ z = 补丁版本号(Patch) 0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录 0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警 0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 -0.2.6 ← 主进程异步收尾与边角防护(当前):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 -0.3.0 ← 新增功能(候选:虚拟滚动 · 仅看差异过滤 · 差异导航快捷键 · 交换左右侧 · 忽略空行 · 主进程解码 worker 化) +0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 +0.3.0 ← 大文件性能与差异导航增强(当前):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 +0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index a57d31c..408ecbd 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -70,7 +70,7 @@ src/renderer/src/ - 完全一致 → 全部 `unchanged`,无差异 - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` -- 忽略选项:`ignoreCase`、`trimWhitespace` 生效与关闭 +- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines` 生效与关闭 - 行切分:结尾换行不产生多余空行 ### 5.2 报告生成(`report.ts`) @@ -79,10 +79,23 @@ src/renderer/src/ - Markdown:表格化、管道符转义保持表格结构 - 格式分发与扩展名映射(`REPORT_EXT`) -### 5.3 应用冒烟(`App.tsx`) -- 渲染应用名 `DiffLens` -- 未加载文件时展示左右空态面板 -- 未加载文件时不显示导出入口(导出仅在加载后可用) +### 5.3 文本工具(`textUtils.ts`) +- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感 +- `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF) +- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 + +### 5.4 对比视图(`DiffView.tsx`) +- 渲染:行内容、行号、词级高亮段、行尾空白标注 +- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 +- 折叠提示行:默认文案与自定义文案(仅看差异视图) +- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo` + +### 5.5 应用冒烟与集成(`App.tsx`) +- 渲染应用名 `DiffLens`;未加载文件时展示左右空态面板、不显示导出入口 +- 加载文件后的对比视图、导出、右键菜单、拖拽、粘贴、容错提示 +- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 +- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 +- 交换左右侧后面板文件互换 --- diff --git a/package.json b/package.json index 7c81585..94b8547 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.2.6", + "version": "0.3.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2cef9a0..416d7ce 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,7 +9,7 @@ import { } from 'react' import { computeDiff } from './diff/diffEngine' import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine' -import DiffView from './components/DiffView' +import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView' import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import TextInputModal from './components/TextInputModal' @@ -88,7 +88,13 @@ function EmptyPane({ export default function App(): ReactElement { const [paneL, setPaneL] = useState(null) const [paneR, setPaneR] = useState(null) - const [options, setOptions] = useState({ trimWhitespace: false, ignoreCase: false }) + const [options, setOptions] = useState({ + trimWhitespace: false, + ignoreCase: false, + ignoreBlankLines: false + }) + // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) + const [onlyDiff, setOnlyDiff] = useState(false) const [activeRowId, setActiveRowId] = useState(null) const [navIndex, setNavIndex] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) @@ -119,11 +125,11 @@ export default function App(): ReactElement { } }, []) - // 超多行预警:行数超过阈值时提示滚动可能卡顿(不阻断加载) + // 超多行预警:行数超过阈值时提示计算耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) const warnHeavyLines = useCallback( (text: string): void => { const lines = countLines(text) - if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),滚动可能出现卡顿`) + if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`) }, [showToast] ) @@ -206,11 +212,46 @@ export default function App(): ReactElement { const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff]) const summary: DiffSummary = diff.summary - // diff 内容变化时重置导航 + // “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位) + const displayRows = useMemo(() => { + if (!onlyDiff) return diff.rows as DisplayItem[] + if (diff.rows.length === 0) return [] as DisplayItem[] + if (changedRows.length === 0) { + const m: FoldMarker = { + fold: true, + skipped: diff.rows.length, + note: '两文件内容完全一致,已折叠全部未更改行' + } + return [m] + } + const out: DisplayItem[] = [] + let skipped = 0 + for (const r of diff.rows) { + if (r.isChanged) { + if (skipped > 0) { + out.push({ fold: true, skipped }) + skipped = 0 + } + out.push(r) + } else { + skipped++ + } + } + if (skipped > 0) out.push({ fold: true, skipped }) + return out + }, [diff, onlyDiff, changedRows.length]) + + // 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新) + const swapPanes = useCallback((): void => { + setPaneL(paneR) + setPaneR(paneL) + }, [paneL, paneR]) + + // diff 内容或视图选项变化时重置导航 useEffect(() => { setNavIndex(0) setActiveRowId(null) - }, [paneL, paneR, options]) + }, [paneL, paneR, options, onlyDiff]) const go = useCallback( (dir: 1 | -1) => { @@ -225,6 +266,18 @@ export default function App(): ReactElement { [changedRows, navIndex] ) + // 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占 + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key !== 'F7') return + if (pasteSide !== null) return + e.preventDefault() + go(e.shiftKey ? -1 : 1) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [go, pasteSide]) + // 行级右键菜单 const onRowContext = useCallback( (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => { @@ -369,7 +422,7 @@ export default function App(): ReactElement { {anyPane ? ( <> void exportReport(fmt)} canExport={anyPane} + onlyDiff={onlyDiff} + onOnlyDiffChange={setOnlyDiff} + onSwap={swapPanes} /> ) : ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index e55410d..30b0d9b 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -26,8 +26,9 @@ function mockApi(overrides?: Partial): typeof window.api { beforeEach(() => { vi.restoreAllMocks() window.api = mockApi() - // jsdom 未实现 scrollIntoView,导航定位需要打桩 + // jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩 Element.prototype.scrollIntoView = vi.fn() + Element.prototype.scrollTo = vi.fn() }) afterEach(() => { @@ -140,7 +141,7 @@ describe('App - 差异导航与清空', () => { fireEvent.click(screen.getByText('打开左侧')) await screen.findByText('导出报告') expect(screen.getByText('1 / 2')).toBeInTheDocument() - fireEvent.click(screen.getByTitle('下一处差异')) + fireEvent.click(screen.getByTitle(/下一处差异/)) expect(screen.getByText('2 / 2')).toBeInTheDocument() }) @@ -428,4 +429,86 @@ describe('App - 手动粘贴文本', () => { expect(screen.getByText('开始对比')).toBeInTheDocument() expect(screen.queryByText('导出报告')).not.toBeInTheDocument() }) +}) + +describe('App - 仅看差异视图', () => { + async function loadBothSides() { + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'same\ndiff-left\nsame2', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'same\ndiff-right\nsame2', encoding: 'UTF-8', binary: false } + }) + const utils = render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + return utils + } + + it('开启后未更改行被折叠并出现折叠提示', async () => { + const { container } = await loadBothSides() + // 默认视图能看到未更改行 + expect(screen.getAllByText('same').length).toBeGreaterThan(0) + fireEvent.click(screen.getByLabelText('仅看差异')) + // 未更改行被折叠 + expect(screen.queryAllByText('same')).toHaveLength(0) + expect(screen.queryAllByText('same2')).toHaveLength(0) + // 差异行仍在(修改行左右两栏各一条);same 与 same2 是两个独立折叠段,左右两栏共 4 条提示 + expect(container.querySelectorAll('.diff-row.modified').length).toBe(2) + expect(screen.getAllByText(/已折叠 1 行相同内容/).length).toBe(4) + }) + + it('关闭开关后恢复全量视图', async () => { + await loadBothSides() + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(screen.queryAllByText('same')).toHaveLength(0) + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(screen.getAllByText('same').length).toBeGreaterThan(0) + }) +}) + +describe('App - F7 / Shift+F7 差异导航快捷键', () => { + it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('1 / 2') + fireEvent.keyDown(window, { key: 'F7' }) + expect(screen.getByText('2 / 2')).toBeInTheDocument() + fireEvent.keyDown(window, { key: 'F7', shiftKey: true }) + expect(screen.getByText('1 / 2')).toBeInTheDocument() + }) + + it('粘贴弹窗打开时 F7 不触发导航', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('1 / 2') + fireEvent.click(screen.getByText('粘贴文本')) + fireEvent.click(screen.getByText('粘贴到左侧')) + expect(screen.getByText('开始对比')).toBeInTheDocument() + fireEvent.keyDown(window, { key: 'F7' }) + // 导航未被触发,计数保持 1 / 2 + expect(screen.getByText('1 / 2')).toBeInTheDocument() + }) +}) + +describe('App - 交换左右侧', () => { + it('交换后左右面板文件互换', async () => { + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'aaa', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'bbb', encoding: 'UTF-8', binary: false } + }) + const { container } = render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + const files = () => Array.from(container.querySelectorAll('.pane-file')).map((e) => e.textContent) + expect(files()).toEqual(['a.txt', 'b.txt']) + fireEvent.click(screen.getByText('⇄ 交换左右')) + expect(files()).toEqual(['b.txt', 'a.txt']) + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/DiffView.test.tsx b/src/renderer/src/components/DiffView.test.tsx index 7c8638a..386fe81 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -1,25 +1,18 @@ import { describe, it, expect, vi, beforeAll } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import DiffView from './DiffView' +import DiffView, { ROW_HEIGHT, type FoldMarker, type DisplayItem } from './DiffView' import type { DiffRow } from '../diff/diffEngine' beforeAll(() => { - // jsdom 未实现 scrollIntoView,测试里打桩 + // jsdom 未实现滚动定位,测试里打桩(虚拟化后导航定位走 scrollTo) + Element.prototype.scrollTo = vi.fn() Element.prototype.scrollIntoView = vi.fn() }) -function renderDiff(rows: DiffRow[], activeRowId: string | null = null) { +function renderDiff(items: DisplayItem[], activeRowId: string | null = null) { return render( r.isChanged).length, - inserted: rows.filter((r) => r.rowKind === 'added').length, - deleted: rows.filter((r) => r.rowKind === 'removed').length, - modified: rows.filter((r) => r.rowKind === 'modified').length - } - }} + items={items} leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} rightMeta={{ name: 'b.txt', encoding: 'GBK' }} activeRowId={activeRowId} @@ -62,9 +55,9 @@ describe('DiffView', () => { expect(screen.getAllByText('2').length).toBeGreaterThan(0) }) - it('设置活动行时调用 scrollIntoView 定位', () => { + it('设置活动行时通过受控 scrollTop 定位(scrollTo)', () => { renderDiff(baseRows, 'r1') - expect(Element.prototype.scrollIntoView).toHaveBeenCalled() + expect(Element.prototype.scrollTo).toHaveBeenCalled() }) it('词级 segs 渲染删除/新增高亮段', () => { @@ -129,10 +122,7 @@ describe('DiffView', () => { const onRowContext = vi.fn() render( { const onOpen = vi.fn() render( { it('拖放文件到面板触发 onDropFile', () => { const onDropFile = vi.fn() - const { container } = (() => { - const utils = render( - - ) - return utils - })() + const { container } = render( + + ) const panes = container.querySelectorAll('.pane') const file = new File(['x'], 'c.txt', { type: 'text/plain' }) fireEvent.drop(panes[0] as Element, { dataTransfer: { files: [file] } }) expect(onDropFile).toHaveBeenCalledWith('left', [file]) }) -}) \ No newline at end of file +}) + +describe('DiffView - 虚拟滚动', () => { + function makeRows(n: number): DiffRow[] { + return Array.from({ length: n }, (_, i) => ({ + id: `v${i}`, + rowKind: 'unchanged' as const, + isChanged: false, + left: { lineNo: i + 1, text: `L${i}`, segs: null }, + right: { lineNo: i + 1, text: `R${i}`, segs: null } + })) + } + + it('大行数下只渲染可见窗口(DOM 行数远小于总量)', () => { + const { container } = renderDiff(makeRows(1000)) + const rows = container.querySelectorAll('.diff-row') + // 两栏合计不超过 (600/21 向上取整 + 2×overscan) × 2 附近的量级 + expect(rows.length).toBeGreaterThan(0) + expect(rows.length).toBeLessThan(200) + }) + + it('滚动后窗口移动到对应区段', () => { + const { container } = renderDiff(makeRows(1000)) + const scrollers = container.querySelectorAll('.diff-scroll') + const left = scrollers[0] as HTMLElement + left.scrollTop = 500 * ROW_HEIGHT + fireEvent.scroll(left) + expect(screen.getAllByText('L500').length).toBeGreaterThan(0) + // 远离窗口的行不再渲染 + expect(screen.queryByText('L0')).not.toBeInTheDocument() + }) + + it('滚动容器总高度按全部行数撑满', () => { + const { container } = renderDiff(makeRows(100)) + const columns = container.querySelectorAll('.diff-columns') as NodeListOf + expect(columns[0].style.height).toBe(`${100 * ROW_HEIGHT}px`) + }) +}) + +describe('DiffView - 折叠提示行', () => { + it('渲染折叠提示与行数', () => { + const items: DisplayItem[] = [ + { fold: true, skipped: 42 }, + { + id: 'r9', + rowKind: 'modified', + isChanged: true, + left: { lineNo: 43, text: 'a', segs: null }, + right: { lineNo: 43, text: 'b', segs: null } + } + ] + const { container } = renderDiff(items) + expect(screen.getAllByText(/已折叠 42 行相同内容/).length).toBe(2) + expect(container.querySelectorAll('.diff-row.fold').length).toBe(2) + }) + + it('自定义文案(完全一致场景)覆盖默认提示', () => { + const m: FoldMarker = { fold: true, skipped: 10, note: '两文件内容完全一致,已折叠全部未更改行' } + renderDiff([m]) + expect(screen.getAllByText('两文件内容完全一致,已折叠全部未更改行').length).toBe(2) + }) +}) diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index f0e3b50..df27d14 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -1,20 +1,46 @@ import { useEffect, + useLayoutEffect, + useMemo, useRef, + useState, type ReactNode, type ReactElement, type RefObject, type MouseEvent as ReactMouseEvent } from 'react' -import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine' +import { DiffRow, SideCell } from '../diff/diffEngine' +import { displayCols } from '../diff/textUtils' export interface PaneMeta { name: string encoding: string } +/** 虚拟行高(px)。与 global.css 的 --line-height: 21px 保持一致,改动需双向同步 */ +export const ROW_HEIGHT = 21 +/** 窗口上下各多渲染的缓冲行数 */ +const OVERSCAN = 8 +/** 视口高度兜底值(jsdom 无布局,clientHeight 为 0 时使用) */ +const FALLBACK_VIEWPORT = 600 +/** 内容最小宽度 = 列宽 × 字符宽 + 行号列与内边距的固定开销 */ +const CONTENT_WIDTH_PAD = 86 + +/** “仅看差异”视图中的折叠提示行(占用一个虚拟行位,高度与其他行一致) */ +export interface FoldMarker { + fold: true + /** 被折叠的未更改行数 */ + skipped: number + /** 自定义提示文案(如“完全一致”场景) */ + note?: string +} + +export type DisplayItem = DiffRow | FoldMarker + +export const isFold = (item: DisplayItem): item is FoldMarker => 'fold' in item + interface DiffViewProps { - diff: DiffResult + items: DisplayItem[] leftMeta: PaneMeta | null rightMeta: PaneMeta | null activeRowId: string | null @@ -51,7 +77,7 @@ function renderCellText(cell: SideCell): ReactNode { } function SidePanel({ - rows, + items, side, meta, onOpen, @@ -59,9 +85,10 @@ function SidePanel({ onDropFile, scrollRef, onScroll, - activeRowId + activeRowId, + minWidth }: { - rows: DiffRow[] + items: DisplayItem[] side: 'left' | 'right' meta: PaneMeta | null onOpen: (side: 'left' | 'right') => void @@ -70,7 +97,38 @@ function SidePanel({ scrollRef: RefObject onScroll: () => void activeRowId: string | null + minWidth: number | undefined }): ReactElement { + // 当前渲染窗口 [start, end),随滚动位置按固定行高换算 + const [range, setRange] = useState({ start: 0, end: 0 }) + + const updateRange = (): void => { + const el = scrollRef.current + if (!el) return + const vh = el.clientHeight || FALLBACK_VIEWPORT + const start = Math.max(0, Math.floor(el.scrollTop / ROW_HEIGHT) - OVERSCAN) + const end = Math.min(items.length, Math.ceil((el.scrollTop + vh) / ROW_HEIGHT) + OVERSCAN) + setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end })) + } + + // 挂载与行集变化时重算窗口;行数未变时窗口按索引自然刷新 + useLayoutEffect(() => { + updateRange() + }, [items]) + + // 窗口尺寸变化时补齐窗口 + useEffect(() => { + const onResize = (): void => updateRange() + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]) + + const handleScroll = (): void => { + updateRange() + onScroll() + } + return (
-
-
- {rows.map((row) => { +
+ {/* 总高度撑满全部行 + paddingTop 平移到窗口起点,仅渲染可见切片 */} +
+ {items.slice(range.start, range.end).map((item, i) => { + const idx = range.start + i + if (isFold(item)) { + return ( +
+ + + {item.note ?? `⋯ 已折叠 ${item.skipped} 行相同内容 ⋯`} + +
+ ) + } + const row = item const cell = side === 'left' ? row.left : row.right const cls = 'diff-row ' + @@ -116,7 +194,7 @@ function SidePanel({ } export default function DiffView({ - diff, + items, leftMeta, rightMeta, activeRowId, @@ -128,6 +206,32 @@ export default function DiffView({ const rightRef = useRef(null) const syncing = useRef(false) + // 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度 + const probeRef = useRef(null) + const [charW, setCharW] = useState(0) + useLayoutEffect(() => { + const w = probeRef.current?.getBoundingClientRect().width ?? 0 + if (w > 0) setCharW(w / 10) + }, []) + + const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => { + let max = 40 + for (const it of rows) { + if (isFold(it)) continue + const t = pick(it).text + if (t) { + const c = displayCols(t) + if (c > max) max = c + } + } + return max + } + const leftCols = useMemo(() => colsOf(items, (r) => r.left), [items]) + const rightCols = useMemo(() => colsOf(items, (r) => r.right), [items]) + // 探针未测得(jsdom/首帧)时不设 minWidth,退回 CSS min-width: max-content + const leftMinW = charW > 0 ? leftCols * charW + CONTENT_WIDTH_PAD : undefined + const rightMinW = charW > 0 ? rightCols * charW + CONTENT_WIDTH_PAD : undefined + const makeScrollSync = (target: 'left' | 'right') => (): void => { @@ -144,18 +248,29 @@ export default function DiffView({ }) } - // 差异导航定位:命中行后滚动到中间,并靠滚动联动自动同步右侧 + // 差异导航定位:目标行换算为受控 scrollTop(虚拟化下行可能未渲染,scrollIntoView 不可用) useEffect(() => { if (!activeRowId) return - const el = leftRef.current?.querySelector(`[data-row="${activeRowId}"]`) - if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) - }, [activeRowId]) + const idx = items.findIndex((it) => !isFold(it) && it.id === activeRowId) + if (idx < 0) return + const vh = leftRef.current?.clientHeight || FALLBACK_VIEWPORT + const maxScroll = Math.max(0, items.length * ROW_HEIGHT - vh) + const target = Math.min( + Math.max(idx * ROW_HEIGHT - Math.floor(vh / 2) + Math.floor(ROW_HEIGHT / 2), 0), + maxScroll + ) + leftRef.current?.scrollTo({ top: target }) + rightRef.current?.scrollTo({ top: target }) + }, [activeRowId, items]) return (
+
) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index ea5c36f..17331be 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -9,9 +9,11 @@ function setup(extra?: { canExport?: boolean }) { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() + const onOnlyDiffChange = vi.fn() + const onSwap = vi.fn() const utils = render( ) - return { onOptionsChange, onNav, onExport, ...utils } + return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils } } describe('Toolbar', () => { @@ -35,24 +40,42 @@ describe('Toolbar', () => { it('切换忽略大小写触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略大小写')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false }) }) it('切换忽略行首尾空白触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略行首尾空白')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false }) + }) + + it('切换忽略空行触发 onOptionsChange', () => { + const { onOptionsChange } = setup() + fireEvent.click(screen.getByLabelText('忽略空行')) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true }) + }) + + it('切换仅看差异触发 onOnlyDiffChange(true)', () => { + const { onOnlyDiffChange } = setup() + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(onOnlyDiffChange).toHaveBeenCalledWith(true) + }) + + it('点击交换左右触发 onSwap', () => { + const { onSwap } = setup() + fireEvent.click(screen.getByText('⇄ 交换左右')) + expect(onSwap).toHaveBeenCalledTimes(1) }) it('点击下一处差异调用 onNav(1)', () => { const { onNav } = setup() - fireEvent.click(screen.getByTitle('下一处差异')) + fireEvent.click(screen.getByTitle(/下一处差异/)) expect(onNav).toHaveBeenCalledWith(1) }) it('点击上一处差异调用 onNav(-1)', () => { const { onNav } = setup() - fireEvent.click(screen.getByTitle('上一处差异')) + fireEvent.click(screen.getByTitle(/上一处差异/)) expect(onNav).toHaveBeenCalledWith(-1) }) diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 3a38b8a..5bfad14 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -18,6 +18,11 @@ interface ToolbarProps { onNav: (dir: 1 | -1) => void onExport: (fmt: ReportFormat) => void canExport: boolean + /** 仅看差异视图开关 */ + onlyDiff: boolean + onOnlyDiffChange: (v: boolean) => void + /** 交换左右两侧 */ + onSwap: () => void } export default function Toolbar({ @@ -28,7 +33,10 @@ export default function Toolbar({ navCount, onNav, onExport, - canExport + canExport, + onlyDiff, + onOnlyDiffChange, + onSwap }: ToolbarProps): ReactElement { const set = (patch: Partial): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) @@ -59,12 +67,35 @@ export default function Toolbar({ /> 忽略大小写 + +
+ +
+ 视图 + +