fix: v0.2.5 一致性与边角修复 - 右键菜单边缘防溢出、粘贴10MB防护、拖拽失败提示、多文件提示、剪贴板走主进程IPC、主进程异步读取

This commit is contained in:
2026-08-17 21:29:17 +08:00
parent 4ee5d62348
commit 9345de1716
13 changed files with 158 additions and 56 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档
- **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
- **滚动联动**:左右两个面板滚动位置自动同步
---
@@ -90,7 +90,7 @@ src/
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.2.4
- 版本:0.2.5
---
+3 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.2.4`**
当前基线版本:**`0.2.5`**
---
@@ -60,7 +60,8 @@ z = 补丁版本号(Patch
0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档
0.2.2 ← 体验打磨(已发布):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
0.2.4 ← 健壮性与体验打磨(当前):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
0.2.5 ← 一致性与边角修复(当前):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
0.3.0 ← 新增功能
...
0.y.z ← 长期停留,永不进入 1.x
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.2.4",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.2.4",
"version": "0.2.5",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.2.4",
"version": "0.2.5",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+2 -2
View File
@@ -102,11 +102,11 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const stat = fs.statSync(filePath)
const stat = await fs.promises.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = fs.readFileSync(filePath)
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed', name }
}
+28 -16
View File
@@ -8,7 +8,7 @@ import {
type MouseEvent as ReactMouseEvent
} from 'react'
import { computeDiff } from './diff/diffEngine'
import type { DiffResult, DiffSummary } from './diff/diffEngine'
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
import DiffView from './components/DiffView'
import Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
@@ -37,11 +37,6 @@ interface PaneState {
text: string
}
interface DiffOpts {
trimWhitespace: boolean
ignoreCase: boolean
}
function LogoIcon(): ReactElement {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
@@ -59,7 +54,7 @@ function EmptyPane({
}: {
side: 'left' | 'right'
onOpen: (s: 'left' | 'right') => void
onDrop: (s: 'left' | 'right', file: File) => void
onDrop: (s: 'left' | 'right', files: File[]) => void
}): ReactElement {
const [drag, setDrag] = useState(false)
return (
@@ -73,8 +68,8 @@ function EmptyPane({
onDrop={(e) => {
e.preventDefault()
setDrag(false)
const f = e.dataTransfer.files[0]
if (f) onDrop(side, f)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) onDrop(side, files)
}}
>
<div>
@@ -93,7 +88,7 @@ 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<DiffOpts>({ trimWhitespace: false, ignoreCase: false })
const [options, setOptions] = useState<DiffOptions>({ trimWhitespace: false, ignoreCase: 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)
@@ -155,7 +150,10 @@ export default function App(): ReactElement {
)
const dropPane = useCallback(
async (side: 'left' | 'right', file: File) => {
async (side: 'left' | 'right', files: File[]) => {
const file = files[0]
if (!file) return
if (files.length > 1) showToast('一次只能导入一个文件,已取第一个')
if (!isTextFile(file.name)) {
showToast(`仅支持文本文件:${file.name}`)
return
@@ -174,7 +172,13 @@ export default function App(): ReactElement {
encoding = decoded.encoding
binary = decoded.binary
} catch {
text = await file.text().catch(() => '')
// IPC 解码失败时回退浏览器原生读取;两者都失败则明确报错,不静默加载空文本
const fallback = await file.text().catch(() => null)
if (fallback === null) {
showToast(`文件读取失败:${file.name}`)
return
}
text = fallback
}
if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`)
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
@@ -219,11 +223,11 @@ export default function App(): ReactElement {
const items: ContextMenuItem[] = [
{
label: `复制${sideName}此行内容`,
action: () => void navigator.clipboard.writeText(cell.text ?? '')
action: () => void window.api.setClipboard(cell.text ?? '')
},
{
label: '复制文件名',
action: () => void navigator.clipboard.writeText(pane?.meta.name ?? '')
action: () => void window.api.setClipboard(pane?.meta.name ?? '')
},
{
label: '在文件夹中显示',
@@ -270,12 +274,20 @@ export default function App(): ReactElement {
const anyPane = paneL !== null || paneR !== null
const confirmPaste = useCallback((side: 'left' | 'right', text: string) => {
const confirmPaste = useCallback(
(side: 'left' | 'right', text: string) => {
// 与文件入口的 10MB 字节上限保持对称:超限拒绝且不关闭弹窗,保留内容供编辑
if (new TextEncoder().encode(text).length > MAX_FILE_SIZE) {
showToast('粘贴内容超过 10MB,暂不支持对比')
return
}
const pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text }
if (side === 'left') setPaneL(pane)
else setPaneR(pane)
setPasteSide(null)
}, [])
},
[showToast]
)
const leftMeta = paneL?.meta ?? null
const rightMeta = paneR?.meta ?? null
+46 -10
View File
@@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import App from '../App'
let clipboardSpy: ReturnType<typeof vi.fn>
function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
// 剪贴板统一走主进程 IPCspy 挂在 setClipboard 上
clipboardSpy = vi.fn(async () => true)
const base = {
openFile: async () => ({
path: '/tmp/a.txt',
@@ -14,23 +18,16 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }),
saveReport: async () => ({ ok: false, path: null }),
showInFolder: async () => true,
setClipboard: async () => true
setClipboard: clipboardSpy
}
return { ...base, ...overrides } as typeof window.api
}
let clipboardSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.restoreAllMocks()
window.api = mockApi()
// jsdom 未实现 scrollIntoView,导航定位需要打桩
Element.prototype.scrollIntoView = vi.fn()
// jsdom 未实现 navigator.clipboard,右键复制需要打桩
clipboardSpy = vi.fn()
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardSpy },
configurable: true
})
})
afterEach(() => {
@@ -175,7 +172,7 @@ describe('App - 右键菜单操作', () => {
expect(screen.queryByText('复制左侧此行内容')).not.toBeInTheDocument()
})
it('执行复制文件名', async () => {
it('执行复制文件名(走主进程剪贴板 IPC', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
@@ -323,6 +320,31 @@ describe('App - 提示与容错', () => {
expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument()
})
it('拖放文件解码与兜底读取均失败时提示且保持空态', async () => {
window.api = mockApi({
decodeBuffer: async () => {
throw new Error('boom')
}
})
vi.spyOn(File.prototype, 'text').mockRejectedValue(new Error('io'))
render(<App />)
const file = new File(['x'], 'bad.txt', { type: 'text/plain' })
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
fireEvent.drop(pane, { dataTransfer: { files: [file] } })
expect(await screen.findByText(/文件读取失败:bad\.txt/)).toBeInTheDocument()
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
})
it('多文件拖拽时提示已取第一个并加载首个文件', async () => {
render(<App />)
const first = new File(['a'], 'first.txt', { type: 'text/plain' })
const second = new File(['b'], 'second.txt', { type: 'text/plain' })
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
fireEvent.drop(pane, { dataTransfer: { files: [first, second] } })
expect(await screen.findByText(/一次只能导入一个文件/)).toBeInTheDocument()
expect(screen.getAllByText('first.txt').length).toBeGreaterThan(0)
})
it('导出时 IPC 异常给出失败提示', async () => {
window.api = mockApi({
saveReport: async () => {
@@ -379,4 +401,18 @@ describe('App - 手动粘贴文本', () => {
fireEvent.click(screen.getByText('开始对比'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
})
it('粘贴内容超过 10MB 被拒绝且弹窗保持打开', async () => {
render(<App />)
fireEvent.click(screen.getByText('粘贴文本'))
fireEvent.click(screen.getByText('粘贴到左侧'))
fireEvent.change(screen.getByPlaceholderText(/粘贴或输入/), {
target: { value: 'x'.repeat(10 * 1024 * 1024 + 1) }
})
fireEvent.click(screen.getByText('开始对比'))
expect(await screen.findByText(/粘贴内容超过 10MB/)).toBeInTheDocument()
// 弹窗保持打开,未进入对比视图
expect(screen.getByText('开始对比')).toBeInTheDocument()
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
})
})
@@ -55,4 +55,45 @@ describe('ContextMenu', () => {
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('靠近右/下边缘时收回视口内', () => {
// 模拟菜单实测尺寸 200x120
const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 120,
top: 0,
left: 0,
right: 200,
bottom: 120,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
render(
<ContextMenu
x={window.innerWidth + 500}
y={window.innerHeight + 500}
items={[{ label: '菜单项', action: vi.fn() }]}
onClose={vi.fn()}
/>
)
const menu = document.querySelector('.ctx-menu') as HTMLElement
expect(parseFloat(menu.style.left)).toBe(window.innerWidth - 8 - 200)
expect(parseFloat(menu.style.top)).toBe(window.innerHeight - 8 - 120)
rectSpy.mockRestore()
})
it('未溢出时保持原始坐标', () => {
render(
<ContextMenu
x={10}
y={10}
items={[{ label: '菜单项', action: vi.fn() }]}
onClose={vi.fn()}
/>
)
const menu = document.querySelector('.ctx-menu') as HTMLElement
expect(menu.style.left).toBe('10px')
expect(menu.style.top).toBe('10px')
})
})
+21 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, type ReactElement } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
export interface ContextMenuItem {
label: string
@@ -19,6 +19,9 @@ export default function ContextMenu({
items,
onClose
}: ContextMenuProps): ReactElement {
const menuRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: x, top: y })
// Esc 关闭菜单
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
@@ -28,6 +31,21 @@ export default function ContextMenu({
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
// 渲染后实测菜单尺寸,溢出视口时收回边界内(右/下各留 8px 边距)
useLayoutEffect(() => {
const el = menuRef.current
if (!el) return
const { width, height } = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const margin = 8
let left = x
let top = y
if (left + width > vw - margin) left = Math.max(margin, vw - margin - width)
if (top + height > vh - margin) top = Math.max(margin, vh - margin - height)
setPos((prev) => (prev.left === left && prev.top === top ? prev : { left, top }))
}, [x, y, items])
return (
<div
className="ctx-backdrop"
@@ -38,8 +56,9 @@ export default function ContextMenu({
}}
>
<div
ref={menuRef}
className="ctx-menu"
style={{ left: x, top: y }}
style={{ left: pos.left, top: pos.top }}
onMouseDown={(e) => e.stopPropagation()}
>
{items.map((it, i) => (
@@ -199,6 +199,6 @@ describe('DiffView', () => {
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)
expect(onDropFile).toHaveBeenCalledWith('left', [file])
})
})
+4 -4
View File
@@ -20,7 +20,7 @@ interface DiffViewProps {
activeRowId: string | null
onOpen: (side: 'left' | 'right') => void
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
onDropFile: (side: 'left' | 'right', file: File) => void
onDropFile: (side: 'left' | 'right', files: File[]) => void
}
/** 把行尾空白用淡色高亮显示,便于识别被忽略/忽略差异 */
@@ -66,7 +66,7 @@ function SidePanel({
meta: PaneMeta | null
onOpen: (side: 'left' | 'right') => void
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
onDropFile: (side: 'left' | 'right', file: File) => void
onDropFile: (side: 'left' | 'right', files: File[]) => void
scrollRef: RefObject<HTMLDivElement>
onScroll: () => void
activeRowId: string | null
@@ -77,8 +77,8 @@ function SidePanel({
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f) onDropFile(side, f)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) onDropFile(side, files)
}}
>
<div className="panel-head">
+1 -6
View File
@@ -1,13 +1,8 @@
import { useCallback, useRef, useState, type ReactElement } from 'react'
import { DiffSummary } from '../diff/diffEngine'
import type { DiffOptions, DiffSummary } from '../diff/diffEngine'
import { type ReportFormat } from '../diff/report'
import { useDismiss } from '../hooks/useDismiss'
export interface DiffOptions {
trimWhitespace: boolean
ignoreCase: boolean
}
const FORMATS: { fmt: ReportFormat; label: string }[] = [
{ fmt: 'html', label: 'HTML 报告' },
{ fmt: 'txt', label: '纯文本' },
+1 -3
View File
@@ -46,8 +46,6 @@ export interface DiffResult {
summary: DiffSummary
}
let rowSeq = 0
function emptyLine(): SideCell {
return { lineNo: null, text: null, segs: null }
}
@@ -89,7 +87,7 @@ export function computeDiff(
rightText: string,
options: DiffOptions = {}
): DiffResult {
rowSeq = 0
let rowSeq = 0
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)