feat: v0.3.0 大文件性能与差异导航增强 - 自研虚拟滚动、仅看差异过滤(折叠提示行)、F7/Shift+F7导航快捷键、交换左右侧、忽略空行

This commit is contained in:
2026-08-17 22:17:57 +08:00
parent b46826334a
commit d910f6c352
15 changed files with 593 additions and 103 deletions
+8 -5
View File
@@ -11,14 +11,17 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,也可直接点击定位 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出滚动卡顿预警 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 - **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行),便于分享与归档
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 - **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
- **滚动联动**:左右两个面板滚动位置自动同步 - **滚动联动**:左右两个面板滚动位置自动同步
@@ -90,7 +93,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.2.6 - 版本:0.3.0
--- ---
+4 -3
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.2.6`** 当前基线版本:**`0.3.0`**
--- ---
@@ -62,8 +62,9 @@ z = 补丁版本号(Patch
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录 0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警 0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取 0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
0.2.6 ← 主进程异步收尾与边角防护(当前):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
0.3.0 ← 新增功能(候选:虚拟滚动 · 仅看差异过滤 · 差异导航快捷键 · 交换左右侧 · 忽略空行 · 主进程解码 worker 化) 0.3.0 ← 大文件性能与差异导航增强(当前):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+18 -5
View File
@@ -70,7 +70,7 @@ src/renderer/src/
- 完全一致 → 全部 `unchanged`,无差异 - 完全一致 → 全部 `unchanged`,无差异
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null` - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase``trimWhitespace` 生效与关闭 - 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines` 生效与关闭
- 行切分:结尾换行不产生多余空行 - 行切分:结尾换行不产生多余空行
### 5.2 报告生成(`report.ts` ### 5.2 报告生成(`report.ts`
@@ -79,10 +79,23 @@ src/renderer/src/
- Markdown:表格化、管道符转义保持表格结构 - Markdown:表格化、管道符转义保持表格结构
- 格式分发与扩展名映射(`REPORT_EXT` - 格式分发与扩展名映射(`REPORT_EXT`
### 5.3 应用冒烟(`App.tsx` ### 5.3 文本工具(`textUtils.ts`
- 渲染应用名 `DiffLens` - 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
- 未加载文件时展示左右空态面板 - `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF)
- 未加载文件时不显示导出入口(导出仅在加载后可用) - `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加
### 5.4 对比视图(`DiffView.tsx`
- 渲染:行内容、行号、词级高亮段、行尾空白标注
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
- 折叠提示行:默认文案与自定义文案(仅看差异视图)
- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo`
### 5.5 应用冒烟与集成(`App.tsx`
- 渲染应用名 `DiffLens`;未加载文件时展示左右空态面板、不显示导出入口
- 加载文件后的对比视图、导出、右键菜单、拖拽、粘贴、容错提示
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.2.6", "version": "0.3.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+63 -7
View File
@@ -9,7 +9,7 @@ import {
} from 'react' } from 'react'
import { computeDiff } from './diff/diffEngine' import { computeDiff } from './diff/diffEngine'
import type { DiffOptions, DiffResult, DiffSummary } 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 Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
import TextInputModal from './components/TextInputModal' import TextInputModal from './components/TextInputModal'
@@ -88,7 +88,13 @@ function EmptyPane({
export default function App(): ReactElement { export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null) const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = 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 [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0) const [navIndex, setNavIndex] = useState(0)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) 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( const warnHeavyLines = useCallback(
(text: string): void => { (text: string): void => {
const lines = countLines(text) const lines = countLines(text)
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),滚动可能出现卡顿`) if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`)
}, },
[showToast] [showToast]
) )
@@ -206,11 +212,46 @@ export default function App(): ReactElement {
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff]) const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
const summary: DiffSummary = diff.summary 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(() => { useEffect(() => {
setNavIndex(0) setNavIndex(0)
setActiveRowId(null) setActiveRowId(null)
}, [paneL, paneR, options]) }, [paneL, paneR, options, onlyDiff])
const go = useCallback( const go = useCallback(
(dir: 1 | -1) => { (dir: 1 | -1) => {
@@ -225,6 +266,18 @@ export default function App(): ReactElement {
[changedRows, navIndex] [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( const onRowContext = useCallback(
(e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => { (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => {
@@ -369,7 +422,7 @@ export default function App(): ReactElement {
{anyPane ? ( {anyPane ? (
<> <>
<DiffView <DiffView
diff={diff} items={displayRows}
leftMeta={leftMeta} leftMeta={leftMeta}
rightMeta={rightMeta} rightMeta={rightMeta}
activeRowId={activeRowId} activeRowId={activeRowId}
@@ -386,6 +439,9 @@ export default function App(): ReactElement {
onNav={go} onNav={go}
onExport={(fmt) => void exportReport(fmt)} onExport={(fmt) => void exportReport(fmt)}
canExport={anyPane} canExport={anyPane}
onlyDiff={onlyDiff}
onOnlyDiffChange={setOnlyDiff}
onSwap={swapPanes}
/> />
</> </>
) : ( ) : (
+85 -2
View File
@@ -26,8 +26,9 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
window.api = mockApi() window.api = mockApi()
// jsdom 未实现 scrollIntoView,导航定位需要打桩 // jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollIntoView = vi.fn()
Element.prototype.scrollTo = vi.fn()
}) })
afterEach(() => { afterEach(() => {
@@ -140,7 +141,7 @@ describe('App - 差异导航与清空', () => {
fireEvent.click(screen.getByText('打开左侧')) fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告') await screen.findByText('导出报告')
expect(screen.getByText('1 / 2')).toBeInTheDocument() expect(screen.getByText('1 / 2')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('下一处差异')) fireEvent.click(screen.getByTitle(/下一处差异/))
expect(screen.getByText('2 / 2')).toBeInTheDocument() expect(screen.getByText('2 / 2')).toBeInTheDocument()
}) })
@@ -429,3 +430,85 @@ describe('App - 手动粘贴文本', () => {
expect(screen.queryByText('导出报告')).not.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'])
})
})
+81 -39
View File
@@ -1,25 +1,18 @@
import { describe, it, expect, vi, beforeAll } from 'vitest' import { describe, it, expect, vi, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' 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' import type { DiffRow } from '../diff/diffEngine'
beforeAll(() => { beforeAll(() => {
// jsdom 未实现 scrollIntoView,测试里打桩 // jsdom 未实现滚动定位,测试里打桩(虚拟化后导航定位走 scrollTo)
Element.prototype.scrollTo = vi.fn()
Element.prototype.scrollIntoView = 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( return render(
<DiffView <DiffView
diff={{ items={items}
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
}
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }} rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={activeRowId} activeRowId={activeRowId}
@@ -62,9 +55,9 @@ describe('DiffView', () => {
expect(screen.getAllByText('2').length).toBeGreaterThan(0) expect(screen.getAllByText('2').length).toBeGreaterThan(0)
}) })
it('设置活动行时调用 scrollIntoView 定位', () => { it('设置活动行时通过受控 scrollTop 定位(scrollTo', () => {
renderDiff(baseRows, 'r1') renderDiff(baseRows, 'r1')
expect(Element.prototype.scrollIntoView).toHaveBeenCalled() expect(Element.prototype.scrollTo).toHaveBeenCalled()
}) })
it('词级 segs 渲染删除/新增高亮段', () => { it('词级 segs 渲染删除/新增高亮段', () => {
@@ -129,10 +122,7 @@ describe('DiffView', () => {
const onRowContext = vi.fn() const onRowContext = vi.fn()
render( render(
<DiffView <DiffView
diff={{ items={baseRows}
rows: baseRows,
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }} rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={null} activeRowId={null}
@@ -149,10 +139,7 @@ describe('DiffView', () => {
const onOpen = vi.fn() const onOpen = vi.fn()
render( render(
<DiffView <DiffView
diff={{ items={baseRows}
rows: baseRows,
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }} rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={null} activeRowId={null}
@@ -179,26 +166,81 @@ describe('DiffView', () => {
it('拖放文件到面板触发 onDropFile', () => { it('拖放文件到面板触发 onDropFile', () => {
const onDropFile = vi.fn() const onDropFile = vi.fn()
const { container } = (() => { const { container } = render(
const utils = render( <DiffView
<DiffView items={baseRows}
diff={{ leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rows: baseRows, rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 } activeRowId={null}
}} onOpen={vi.fn()}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} onRowContext={vi.fn()}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }} onDropFile={onDropFile}
activeRowId={null} />
onOpen={vi.fn()} )
onRowContext={vi.fn()}
onDropFile={onDropFile}
/>
)
return utils
})()
const panes = container.querySelectorAll('.pane') const panes = container.querySelectorAll('.pane')
const file = new File(['x'], 'c.txt', { type: 'text/plain' }) const file = new File(['x'], 'c.txt', { type: 'text/plain' })
fireEvent.drop(panes[0] as Element, { dataTransfer: { files: [file] } }) fireEvent.drop(panes[0] as Element, { dataTransfer: { files: [file] } })
expect(onDropFile).toHaveBeenCalledWith('left', [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)
})
})
+132 -15
View File
@@ -1,20 +1,46 @@
import { import {
useEffect, useEffect,
useLayoutEffect,
useMemo,
useRef, useRef,
useState,
type ReactNode, type ReactNode,
type ReactElement, type ReactElement,
type RefObject, type RefObject,
type MouseEvent as ReactMouseEvent type MouseEvent as ReactMouseEvent
} from 'react' } from 'react'
import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine' import { DiffRow, SideCell } from '../diff/diffEngine'
import { displayCols } from '../diff/textUtils'
export interface PaneMeta { export interface PaneMeta {
name: string name: string
encoding: 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 { interface DiffViewProps {
diff: DiffResult items: DisplayItem[]
leftMeta: PaneMeta | null leftMeta: PaneMeta | null
rightMeta: PaneMeta | null rightMeta: PaneMeta | null
activeRowId: string | null activeRowId: string | null
@@ -51,7 +77,7 @@ function renderCellText(cell: SideCell): ReactNode {
} }
function SidePanel({ function SidePanel({
rows, items,
side, side,
meta, meta,
onOpen, onOpen,
@@ -59,9 +85,10 @@ function SidePanel({
onDropFile, onDropFile,
scrollRef, scrollRef,
onScroll, onScroll,
activeRowId activeRowId,
minWidth
}: { }: {
rows: DiffRow[] items: DisplayItem[]
side: 'left' | 'right' side: 'left' | 'right'
meta: PaneMeta | null meta: PaneMeta | null
onOpen: (side: 'left' | 'right') => void onOpen: (side: 'left' | 'right') => void
@@ -70,7 +97,38 @@ function SidePanel({
scrollRef: RefObject<HTMLDivElement> scrollRef: RefObject<HTMLDivElement>
onScroll: () => void onScroll: () => void
activeRowId: string | null activeRowId: string | null
minWidth: number | undefined
}): ReactElement { }): 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 ( return (
<div <div
className={'pane pane-mid pane-' + side} className={'pane pane-mid pane-' + side}
@@ -91,9 +149,29 @@ function SidePanel({
</button> </button>
</span> </span>
</div> </div>
<div className="diff-scroll" ref={scrollRef} onScroll={onScroll}> <div className="diff-scroll" ref={scrollRef} onScroll={handleScroll}>
<div className="diff-columns"> {/* 总高度撑满全部行 + paddingTop 平移到窗口起点,仅渲染可见切片 */}
{rows.map((row) => { <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 cell = side === 'left' ? row.left : row.right
const cls = const cls =
'diff-row ' + 'diff-row ' +
@@ -116,7 +194,7 @@ function SidePanel({
} }
export default function DiffView({ export default function DiffView({
diff, items,
leftMeta, leftMeta,
rightMeta, rightMeta,
activeRowId, activeRowId,
@@ -128,6 +206,32 @@ export default function DiffView({
const rightRef = useRef<HTMLDivElement>(null) const rightRef = useRef<HTMLDivElement>(null)
const syncing = useRef(false) 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 = const makeScrollSync =
(target: 'left' | 'right') => (target: 'left' | 'right') =>
(): void => { (): void => {
@@ -144,18 +248,29 @@ export default function DiffView({
}) })
} }
// 差异导航定位:命中行后滚动到中间,并靠滚动联动自动同步右侧 // 差异导航定位:目标行换算为受控 scrollTop(虚拟化下行可能未渲染,scrollIntoView 不可用)
useEffect(() => { useEffect(() => {
if (!activeRowId) return if (!activeRowId) return
const el = leftRef.current?.querySelector<HTMLElement>(`[data-row="${activeRowId}"]`) const idx = items.findIndex((it) => !isFold(it) && it.id === activeRowId)
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) if (idx < 0) return
}, [activeRowId]) 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 ( return (
<div className="diff-view"> <div className="diff-view">
<span className="width-probe" ref={probeRef} aria-hidden="true">
0000000000
</span>
<div className="diff-main"> <div className="diff-main">
<SidePanel <SidePanel
rows={diff.rows} items={items}
side="left" side="left"
meta={leftMeta} meta={leftMeta}
onOpen={onOpen} onOpen={onOpen}
@@ -164,9 +279,10 @@ export default function DiffView({
scrollRef={leftRef} scrollRef={leftRef}
onScroll={makeScrollSync('left')} onScroll={makeScrollSync('left')}
activeRowId={activeRowId} activeRowId={activeRowId}
minWidth={leftMinW}
/> />
<SidePanel <SidePanel
rows={diff.rows} items={items}
side="right" side="right"
meta={rightMeta} meta={rightMeta}
onOpen={onOpen} onOpen={onOpen}
@@ -175,6 +291,7 @@ export default function DiffView({
scrollRef={rightRef} scrollRef={rightRef}
onScroll={makeScrollSync('right')} onScroll={makeScrollSync('right')}
activeRowId={activeRowId} activeRowId={activeRowId}
minWidth={rightMinW}
/> />
</div> </div>
</div> </div>
+29 -6
View File
@@ -9,9 +9,11 @@ function setup(extra?: { canExport?: boolean }) {
const onOptionsChange = vi.fn() const onOptionsChange = vi.fn()
const onNav = vi.fn() const onNav = vi.fn()
const onExport = vi.fn() const onExport = vi.fn()
const onOnlyDiffChange = vi.fn()
const onSwap = vi.fn()
const utils = render( const utils = render(
<Toolbar <Toolbar
options={{ trimWhitespace: false, ignoreCase: false }} options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
onOptionsChange={onOptionsChange} onOptionsChange={onOptionsChange}
summary={summary} summary={summary}
navIndex={0} navIndex={0}
@@ -19,9 +21,12 @@ function setup(extra?: { canExport?: boolean }) {
onNav={onNav} onNav={onNav}
onExport={onExport} onExport={onExport}
canExport={extra?.canExport ?? true} canExport={extra?.canExport ?? true}
onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap}
/> />
) )
return { onOptionsChange, onNav, onExport, ...utils } return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
} }
describe('Toolbar', () => { describe('Toolbar', () => {
@@ -35,24 +40,42 @@ describe('Toolbar', () => {
it('切换忽略大小写触发 onOptionsChange', () => { it('切换忽略大小写触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略大小写')) fireEvent.click(screen.getByLabelText('忽略大小写'))
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true }) expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false })
}) })
it('切换忽略行首尾空白触发 onOptionsChange', () => { it('切换忽略行首尾空白触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略行首尾空白')) 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)', () => { it('点击下一处差异调用 onNav(1)', () => {
const { onNav } = setup() const { onNav } = setup()
fireEvent.click(screen.getByTitle('下一处差异')) fireEvent.click(screen.getByTitle(/下一处差异/))
expect(onNav).toHaveBeenCalledWith(1) expect(onNav).toHaveBeenCalledWith(1)
}) })
it('点击上一处差异调用 onNav(-1)', () => { it('点击上一处差异调用 onNav(-1)', () => {
const { onNav } = setup() const { onNav } = setup()
fireEvent.click(screen.getByTitle('上一处差异')) fireEvent.click(screen.getByTitle(/上一处差异/))
expect(onNav).toHaveBeenCalledWith(-1) expect(onNav).toHaveBeenCalledWith(-1)
}) })
+34 -3
View File
@@ -18,6 +18,11 @@ interface ToolbarProps {
onNav: (dir: 1 | -1) => void onNav: (dir: 1 | -1) => void
onExport: (fmt: ReportFormat) => void onExport: (fmt: ReportFormat) => void
canExport: boolean canExport: boolean
/** 仅看差异视图开关 */
onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void
/** 交换左右两侧 */
onSwap: () => void
} }
export default function Toolbar({ export default function Toolbar({
@@ -28,7 +33,10 @@ export default function Toolbar({
navCount, navCount,
onNav, onNav,
onExport, onExport,
canExport canExport,
onlyDiff,
onOnlyDiffChange,
onSwap
}: ToolbarProps): ReactElement { }: ToolbarProps): ReactElement {
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch }) const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
@@ -59,12 +67,35 @@ export default function Toolbar({
/> />
</label> </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>
<div className="tool-group"> <div className="tool-group">
<button <button
className="nav-btn" className="nav-btn"
title="上一处差异" title="上一处差异Shift+F7"
disabled={!canNav} disabled={!canNav}
onClick={() => onNav(-1)} onClick={() => onNav(-1)}
> >
@@ -75,7 +106,7 @@ export default function Toolbar({
</span> </span>
<button <button
className="nav-btn" className="nav-btn"
title="下一处差异" title="下一处差异F7"
disabled={!canNav} disabled={!canNav}
onClick={() => onNav(1)} onClick={() => onNav(1)}
> >
+33
View File
@@ -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 - 行切分细节', () => { describe('computeDiff - 行切分细节', () => {
it('结尾换行不产生多余空行', () => { it('结尾换行不产生多余空行', () => {
const { rows, summary } = computeDiff('a\n', 'a') const { rows, summary } = computeDiff('a\n', 'a')
+33 -14
View File
@@ -39,6 +39,8 @@ export interface DiffOptions {
trimWhitespace?: boolean trimWhitespace?: boolean
/** 忽略大小写 */ /** 忽略大小写 */
ignoreCase?: boolean ignoreCase?: boolean
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
ignoreBlankLines?: boolean
} }
export interface DiffResult { export interface DiffResult {
@@ -50,6 +52,12 @@ function emptyLine(): SideCell {
return { lineNo: null, text: null, segs: null } return { lineNo: null, text: null, segs: null }
} }
/** 带原始行号的行(忽略空行模式下空行被剔除,行号保留原值以维持定位) */
interface NumberedLine {
text: string
lineNo: number
}
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */ /** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } { function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
const parts = diffWordsWithSpace(leftText, rightText) const parts = diffWordsWithSpace(leftText, rightText)
@@ -80,7 +88,8 @@ export function splitLines(text: string): string[] {
* 计算文本差异。 * 计算文本差异。
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同; * diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
* 删除段与新增段配对为“修改”行并做词级内联高亮。 * 删除段与新增段配对为“修改”行并做词级内联高亮。
* 展示文本始终取原始行,选项只影响“是否判等”。 * trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
*/ */
export function computeDiff( export function computeDiff(
leftText: string, leftText: string,
@@ -91,21 +100,31 @@ export function computeDiff(
const leftOrig = splitLines(leftText) const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText) 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 => { const norm = (s: string): string => {
let v = options.trimWhitespace ? s.trim() : s let v = options.trimWhitespace ? s.trim() : s
if (options.ignoreCase) v = v.toLowerCase() if (options.ignoreCase) v = v.toLowerCase()
return v return v
} }
const leftNorm = leftOrig.map(norm) const leftNorm = leftUsed.map((p) => norm(p.text))
const rightNorm = rightOrig.map(norm) const rightNorm = rightUsed.map((p) => norm(p.text))
const parts = diffArrays(leftNorm, rightNorm) const parts = diffArrays(leftNorm, rightNorm)
const rows: DiffRow[] = [] const rows: DiffRow[] = []
let leftPtr = 1 // 左侧下一个待消费行号(1 基 let leftPtr = 0 // 左侧下一个待消费下标(0 基,指向 leftUsed
let rightPtr = 1 let rightPtr = 0
// 暂存的纯删除行(等待与随后新增配对) // 暂存的纯删除行(等待与随后新增配对)
let queuedRemoved: { text: string; lineNo: number }[] = [] let queuedRemoved: NumberedLine[] = []
const flushRemoved = (): void => { const flushRemoved = (): void => {
for (const r of queuedRemoved) { 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) const count = Math.max(queuedRemoved.length, addedRows.length)
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const l = queuedRemoved[i] const l = queuedRemoved[i]
@@ -161,14 +180,14 @@ export function computeDiff(
if (part.removed) { if (part.removed) {
const len = value.length const len = value.length
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
queuedRemoved.push({ text: leftOrig[leftPtr - 1 + i], lineNo: leftPtr + i }) queuedRemoved.push(leftUsed[leftPtr + i])
} }
leftPtr += len leftPtr += len
} else if (part.added) { } else if (part.added) {
const len = value.length const len = value.length
const addedRows: { text: string; lineNo: number }[] = [] const addedRows: NumberedLine[] = []
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
addedRows.push({ text: rightOrig[rightPtr - 1 + i], lineNo: rightPtr + i }) addedRows.push(rightUsed[rightPtr + i])
} }
rightPtr += len rightPtr += len
if (queuedRemoved.length > 0) { if (queuedRemoved.length > 0) {
@@ -189,14 +208,14 @@ export function computeDiff(
if (queuedRemoved.length > 0) flushRemoved() if (queuedRemoved.length > 0) flushRemoved()
const len = value.length const len = value.length
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const lt = leftOrig[leftPtr - 1 + i] const lp = leftUsed[leftPtr + i]
const rt = rightOrig[rightPtr - 1 + i] const rp = rightUsed[rightPtr + i]
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rowSeq++}`,
rowKind: 'unchanged', rowKind: 'unchanged',
isChanged: false, isChanged: false,
left: { lineNo: leftPtr + i, text: lt, segs: null }, left: { lineNo: lp.lineNo, text: lp.text, segs: null },
right: { lineNo: rightPtr + i, text: rt, segs: null } right: { lineNo: rp.lineNo, text: rp.text, segs: null }
}) })
} }
leftPtr += len leftPtr += len
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' 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', () => { describe('textUtils', () => {
it('常见文本扩展名放行', () => { it('常见文本扩展名放行', () => {
@@ -53,3 +53,19 @@ describe('countLines / HEAVY_LINES', () => {
expect(HEAVY_LINES).toBe(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)
})
})
+28
View File
@@ -82,3 +82,31 @@ export const HEAVY_LINES = 50000
export function countLines(text: string): number { export function countLines(text: string): number {
return splitLines(text).length 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
}
+25
View File
@@ -378,6 +378,31 @@ button:disabled {
color: transparent; 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 { .toolbar {
flex: 0 0 auto; flex: 0 0 auto;