fix: v0.2.5 一致性与边角修复 - 右键菜单边缘防溢出、粘贴10MB防护、拖拽失败提示、多文件提示、剪贴板走主进程IPC、主进程异步读取
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
+32
-20
@@ -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 pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text }
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
setPasteSide(null)
|
||||
}, [])
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
// 剪贴板统一走主进程 IPC,spy 挂在 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')
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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,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: '纯文本' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user