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
+63 -7
View File
@@ -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}
/>
</>
) : (
+85 -2
View File
@@ -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'])
})
})
+82 -40
View File
@@ -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)
})
})
+133 -16
View File
@@ -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>
)
}
}
+29 -6
View File
@@ -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)
})
+34 -3
View File
@@ -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)}
>
+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 - 行切分细节', () => {
it('结尾换行不产生多余空行', () => {
const { rows, summary } = computeDiff('a\n', 'a')
+33 -14
View File
@@ -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
+17 -1
View File
@@ -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)
})
})
+28
View File
@@ -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
}
+25
View File
@@ -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;