feat: v0.3.0 大文件性能与差异导航增强 - 自研虚拟滚动、仅看差异过滤(折叠提示行)、F7/Shift+F7导航快捷键、交换左右侧、忽略空行
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-3
@@ -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
|
||||
```
|
||||
|
||||
+18
-5
@@ -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 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
||||
- 交换左右侧后面板文件互换
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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<PaneState | null>(null)
|
||||
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
||||
const [options, setOptions] = useState<DiffOptions>({ trimWhitespace: false, ignoreCase: false })
|
||||
const [options, setOptions] = useState<DiffOptions>({
|
||||
trimWhitespace: false,
|
||||
ignoreCase: false,
|
||||
ignoreBlankLines: false
|
||||
})
|
||||
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||
const [onlyDiff, setOnlyDiff] = useState(false)
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(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 ? (
|
||||
<>
|
||||
<DiffView
|
||||
diff={diff}
|
||||
items={displayRows}
|
||||
leftMeta={leftMeta}
|
||||
rightMeta={rightMeta}
|
||||
activeRowId={activeRowId}
|
||||
@@ -386,6 +439,9 @@ export default function App(): ReactElement {
|
||||
onNav={go}
|
||||
onExport={(fmt) => void exportReport(fmt)}
|
||||
canExport={anyPane}
|
||||
onlyDiff={onlyDiff}
|
||||
onOnlyDiffChange={setOnlyDiff}
|
||||
onSwap={swapPanes}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -26,8 +26,9 @@ function mockApi(overrides?: Partial<typeof window.api>): 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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<DiffView
|
||||
diff={{
|
||||
rows,
|
||||
summary: {
|
||||
changedLines: rows.filter((r) => 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(
|
||||
<DiffView
|
||||
diff={{
|
||||
rows: baseRows,
|
||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
||||
}}
|
||||
items={baseRows}
|
||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||
activeRowId={null}
|
||||
@@ -149,10 +139,7 @@ describe('DiffView', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
<DiffView
|
||||
diff={{
|
||||
rows: baseRows,
|
||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
||||
}}
|
||||
items={baseRows}
|
||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||
activeRowId={null}
|
||||
@@ -179,26 +166,81 @@ describe('DiffView', () => {
|
||||
|
||||
it('拖放文件到面板触发 onDropFile', () => {
|
||||
const onDropFile = vi.fn()
|
||||
const { container } = (() => {
|
||||
const utils = render(
|
||||
<DiffView
|
||||
diff={{
|
||||
rows: baseRows,
|
||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
||||
}}
|
||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||
activeRowId={null}
|
||||
onOpen={vi.fn()}
|
||||
onRowContext={vi.fn()}
|
||||
onDropFile={onDropFile}
|
||||
/>
|
||||
)
|
||||
return utils
|
||||
})()
|
||||
const { container } = render(
|
||||
<DiffView
|
||||
items={baseRows}
|
||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||
activeRowId={null}
|
||||
onOpen={vi.fn()}
|
||||
onRowContext={vi.fn()}
|
||||
onDropFile={onDropFile}
|
||||
/>
|
||||
)
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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<HTMLElement>
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HTMLDivElement>
|
||||
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 (
|
||||
<div
|
||||
className={'pane pane-mid pane-' + side}
|
||||
@@ -91,9 +149,29 @@ function SidePanel({
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="diff-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="diff-columns">
|
||||
{rows.map((row) => {
|
||||
<div className="diff-scroll" ref={scrollRef} onScroll={handleScroll}>
|
||||
{/* 总高度撑满全部行 + paddingTop 平移到窗口起点,仅渲染可见切片 */}
|
||||
<div
|
||||
className="diff-columns"
|
||||
style={{
|
||||
height: items.length * ROW_HEIGHT,
|
||||
minWidth,
|
||||
paddingTop: range.start * ROW_HEIGHT
|
||||
}}
|
||||
>
|
||||
{items.slice(range.start, range.end).map((item, i) => {
|
||||
const idx = range.start + i
|
||||
if (isFold(item)) {
|
||||
return (
|
||||
<div className="diff-row fold" key={'fold-' + idx}>
|
||||
<span className={'ln ' + side} />
|
||||
<span className="tx fold-note">
|
||||
{item.note ?? `⋯ 已折叠 ${item.skipped} 行相同内容 ⋯`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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<HTMLDivElement>(null)
|
||||
const syncing = useRef(false)
|
||||
|
||||
// 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度
|
||||
const probeRef = useRef<HTMLSpanElement>(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<HTMLElement>(`[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 (
|
||||
<div className="diff-view">
|
||||
<span className="width-probe" ref={probeRef} aria-hidden="true">
|
||||
0000000000
|
||||
</span>
|
||||
<div className="diff-main">
|
||||
<SidePanel
|
||||
rows={diff.rows}
|
||||
items={items}
|
||||
side="left"
|
||||
meta={leftMeta}
|
||||
onOpen={onOpen}
|
||||
@@ -164,9 +279,10 @@ export default function DiffView({
|
||||
scrollRef={leftRef}
|
||||
onScroll={makeScrollSync('left')}
|
||||
activeRowId={activeRowId}
|
||||
minWidth={leftMinW}
|
||||
/>
|
||||
<SidePanel
|
||||
rows={diff.rows}
|
||||
items={items}
|
||||
side="right"
|
||||
meta={rightMeta}
|
||||
onOpen={onOpen}
|
||||
@@ -175,8 +291,9 @@ export default function DiffView({
|
||||
scrollRef={rightRef}
|
||||
onScroll={makeScrollSync('right')}
|
||||
activeRowId={activeRowId}
|
||||
minWidth={rightMinW}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<Toolbar
|
||||
options={{ trimWhitespace: false, ignoreCase: false }}
|
||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
|
||||
onOptionsChange={onOptionsChange}
|
||||
summary={summary}
|
||||
navIndex={0}
|
||||
@@ -19,9 +21,12 @@ function setup(extra?: { canExport?: boolean }) {
|
||||
onNav={onNav}
|
||||
onExport={onExport}
|
||||
canExport={extra?.canExport ?? true}
|
||||
onlyDiff={false}
|
||||
onOnlyDiffChange={onOnlyDiffChange}
|
||||
onSwap={onSwap}
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -59,12 +67,35 @@ export default function Toolbar({
|
||||
/>
|
||||
忽略大小写
|
||||
</label>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.ignoreBlankLines ?? false}
|
||||
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
|
||||
/>
|
||||
忽略空行
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
<span className="tool-label">视图</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyDiff}
|
||||
onChange={(e) => onOnlyDiffChange(e.target.checked)}
|
||||
/>
|
||||
仅看差异
|
||||
</label>
|
||||
<button className="btn" onClick={onSwap} title="交换左右两侧内容">
|
||||
⇄ 交换左右
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
<button
|
||||
className="nav-btn"
|
||||
title="上一处差异"
|
||||
title="上一处差异(Shift+F7)"
|
||||
disabled={!canNav}
|
||||
onClick={() => onNav(-1)}
|
||||
>
|
||||
@@ -75,7 +106,7 @@ export default function Toolbar({
|
||||
</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
title="下一处差异"
|
||||
title="下一处差异(F7)"
|
||||
disabled={!canNav}
|
||||
onClick={() => onNav(1)}
|
||||
>
|
||||
|
||||
@@ -115,6 +115,39 @@ describe('computeDiff - 忽略选项', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 忽略空行', () => {
|
||||
it('开启后单侧空行不产生差异', () => {
|
||||
const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreBlankLines: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('未开启时单侧空行仍被识别为删除', () => {
|
||||
const { summary } = computeDiff('a\n\nb', 'a\nb')
|
||||
expect(summary.deleted).toBe(1)
|
||||
})
|
||||
|
||||
it('空行被剔除后内容行差异仍被识别', () => {
|
||||
const { rows, summary } = computeDiff('a\n\nb', 'a\nx\nb', { ignoreBlankLines: true })
|
||||
expect(summary.changedLines).toBe(1)
|
||||
expect(summary.inserted).toBe(1)
|
||||
// 行号保留原始文件位置(x 位于右侧第 2 行)
|
||||
const added = rows.find((r) => r.rowKind === 'added')
|
||||
expect(added!.right.lineNo).toBe(2)
|
||||
})
|
||||
|
||||
it('两侧不同空白字符的空行互相判等', () => {
|
||||
const { summary } = computeDiff('a\n \nb', 'a\n\t\nb', { ignoreBlankLines: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('剔除空行后行号保留原值', () => {
|
||||
const { rows } = computeDiff('a\n\n\nb', 'a\n\n\nb', { ignoreBlankLines: true })
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[1].left.lineNo).toBe(4)
|
||||
expect(rows[1].right.lineNo).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 行切分细节', () => {
|
||||
it('结尾换行不产生多余空行', () => {
|
||||
const { rows, summary } = computeDiff('a\n', 'a')
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface DiffOptions {
|
||||
trimWhitespace?: boolean
|
||||
/** 忽略大小写 */
|
||||
ignoreCase?: boolean
|
||||
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
|
||||
ignoreBlankLines?: boolean
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
@@ -50,6 +52,12 @@ function emptyLine(): SideCell {
|
||||
return { lineNo: null, text: null, segs: null }
|
||||
}
|
||||
|
||||
/** 带原始行号的行(忽略空行模式下空行被剔除,行号保留原值以维持定位) */
|
||||
interface NumberedLine {
|
||||
text: string
|
||||
lineNo: number
|
||||
}
|
||||
|
||||
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
|
||||
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
|
||||
const parts = diffWordsWithSpace(leftText, rightText)
|
||||
@@ -80,7 +88,8 @@ export function splitLines(text: string): string[] {
|
||||
* 计算文本差异。
|
||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||
* 展示文本始终取原始行,选项只影响“是否判等”。
|
||||
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
|
||||
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
||||
*/
|
||||
export function computeDiff(
|
||||
leftText: string,
|
||||
@@ -91,21 +100,31 @@ export function computeDiff(
|
||||
const leftOrig = splitLines(leftText)
|
||||
const rightOrig = splitLines(rightText)
|
||||
|
||||
// 行号与行文本绑定;忽略空行模式下剔除空白行(行号保留原值,便于定位原始文件位置)
|
||||
const leftSeq: NumberedLine[] = leftOrig.map((text, i) => ({ text, lineNo: i + 1 }))
|
||||
const rightSeq: NumberedLine[] = rightOrig.map((text, i) => ({ text, lineNo: i + 1 }))
|
||||
const leftUsed = options.ignoreBlankLines
|
||||
? leftSeq.filter((p) => p.text.trim() !== '')
|
||||
: leftSeq
|
||||
const rightUsed = options.ignoreBlankLines
|
||||
? rightSeq.filter((p) => p.text.trim() !== '')
|
||||
: rightSeq
|
||||
|
||||
const norm = (s: string): string => {
|
||||
let v = options.trimWhitespace ? s.trim() : s
|
||||
if (options.ignoreCase) v = v.toLowerCase()
|
||||
return v
|
||||
}
|
||||
const leftNorm = leftOrig.map(norm)
|
||||
const rightNorm = rightOrig.map(norm)
|
||||
const leftNorm = leftUsed.map((p) => norm(p.text))
|
||||
const rightNorm = rightUsed.map((p) => norm(p.text))
|
||||
|
||||
const parts = diffArrays(leftNorm, rightNorm)
|
||||
const rows: DiffRow[] = []
|
||||
let leftPtr = 1 // 左侧下一个待消费行号(1 基)
|
||||
let rightPtr = 1
|
||||
let leftPtr = 0 // 左侧下一个待消费下标(0 基,指向 leftUsed)
|
||||
let rightPtr = 0
|
||||
|
||||
// 暂存的纯删除行(等待与随后新增配对)
|
||||
let queuedRemoved: { text: string; lineNo: number }[] = []
|
||||
let queuedRemoved: NumberedLine[] = []
|
||||
|
||||
const flushRemoved = (): void => {
|
||||
for (const r of queuedRemoved) {
|
||||
@@ -121,7 +140,7 @@ export function computeDiff(
|
||||
}
|
||||
|
||||
/** 删除段与新增段配对为修改行 */
|
||||
const commitModified = (addedRows: { text: string; lineNo: number }[]): void => {
|
||||
const commitModified = (addedRows: NumberedLine[]): void => {
|
||||
const count = Math.max(queuedRemoved.length, addedRows.length)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const l = queuedRemoved[i]
|
||||
@@ -161,14 +180,14 @@ export function computeDiff(
|
||||
if (part.removed) {
|
||||
const len = value.length
|
||||
for (let i = 0; i < len; i++) {
|
||||
queuedRemoved.push({ text: leftOrig[leftPtr - 1 + i], lineNo: leftPtr + i })
|
||||
queuedRemoved.push(leftUsed[leftPtr + i])
|
||||
}
|
||||
leftPtr += len
|
||||
} else if (part.added) {
|
||||
const len = value.length
|
||||
const addedRows: { text: string; lineNo: number }[] = []
|
||||
const addedRows: NumberedLine[] = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
addedRows.push({ text: rightOrig[rightPtr - 1 + i], lineNo: rightPtr + i })
|
||||
addedRows.push(rightUsed[rightPtr + i])
|
||||
}
|
||||
rightPtr += len
|
||||
if (queuedRemoved.length > 0) {
|
||||
@@ -189,14 +208,14 @@ export function computeDiff(
|
||||
if (queuedRemoved.length > 0) flushRemoved()
|
||||
const len = value.length
|
||||
for (let i = 0; i < len; i++) {
|
||||
const lt = leftOrig[leftPtr - 1 + i]
|
||||
const rt = rightOrig[rightPtr - 1 + i]
|
||||
const lp = leftUsed[leftPtr + i]
|
||||
const rp = rightUsed[rightPtr + i]
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: { lineNo: leftPtr + i, text: lt, segs: null },
|
||||
right: { lineNo: rightPtr + i, text: rt, segs: null }
|
||||
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
|
||||
right: { lineNo: rp.lineNo, text: rp.text, segs: null }
|
||||
})
|
||||
}
|
||||
leftPtr += len
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines } from './textUtils'
|
||||
import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines, displayCols } from './textUtils'
|
||||
|
||||
describe('textUtils', () => {
|
||||
it('常见文本扩展名放行', () => {
|
||||
@@ -52,4 +52,20 @@ describe('countLines / HEAVY_LINES', () => {
|
||||
it('超多行预警阈值为 50000', () => {
|
||||
expect(HEAVY_LINES).toBe(50000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayCols 列宽估算', () => {
|
||||
it('半角字符按 1 列计', () => {
|
||||
expect(displayCols('hello')).toBe(5)
|
||||
expect(displayCols('')).toBe(0)
|
||||
})
|
||||
|
||||
it('CJK 与全角字符按 2 列计', () => {
|
||||
expect(displayCols('中文')).toBe(4)
|
||||
expect(displayCols('ab')).toBe(4)
|
||||
})
|
||||
|
||||
it('中英混排按叠加列宽计', () => {
|
||||
expect(displayCols('a中b')).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -81,4 +81,32 @@ export const HEAVY_LINES = 50000
|
||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||
export function countLines(text: string): number {
|
||||
return splitLines(text).length
|
||||
}
|
||||
|
||||
/** 判断码点是否为双宽字符(CJK / 全角 / 韩文 / 常见 emoji 区段) */
|
||||
function isWideCodePoint(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // 谚文字母
|
||||
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK 部首 ~ 彝文
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || // 谚文音节
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 兼容表意
|
||||
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK 兼容形式
|
||||
(cp >= 0xff00 && cp <= 0xff60) || // 全角形式
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
||||
(cp >= 0x1f300 && cp <= 0x1f9ff) || // emoji 区段
|
||||
(cp >= 0x20000 && cp <= 0x3fffd) || // CJK 扩展 B+
|
||||
cp === 0x3000 // 全角空格
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。
|
||||
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
|
||||
*/
|
||||
export function displayCols(text: string): number {
|
||||
let n = 0
|
||||
for (const ch of text) {
|
||||
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -378,6 +378,31 @@ button:disabled {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* ============ 折叠提示行(仅看差异视图) ============ */
|
||||
.diff-row.fold {
|
||||
background: rgba(148, 163, 255, 0.04);
|
||||
}
|
||||
.diff-row.fold .ln {
|
||||
background: rgba(148, 163, 255, 0.03);
|
||||
}
|
||||
.fold-note {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ============ 虚拟滚动宽度探针 ============ */
|
||||
.width-probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============ 工具栏 ============ */
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user