From 9345de17164d3fd4da93767691e17a42034bafa4 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 17 Aug 2026 21:29:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.2.5=20=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E4=B8=8E=E8=BE=B9=E8=A7=92=E4=BF=AE=E5=A4=8D=20-=20=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E8=BE=B9=E7=BC=98=E9=98=B2=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E3=80=81=E7=B2=98=E8=B4=B410MB=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=E3=80=81=E6=8B=96=E6=8B=BD=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E5=A4=9A=E6=96=87=E4=BB=B6=E6=8F=90=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=E8=B5=B0=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?IPC=E3=80=81=E4=B8=BB=E8=BF=9B=E7=A8=8B=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/应用开发与版本迭代规范.md | 5 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 4 +- src/renderer/src/App.tsx | 52 ++++++++++------- src/renderer/src/__tests__/App.test.tsx | 56 +++++++++++++++---- .../src/components/ContextMenu.test.tsx | 41 ++++++++++++++ src/renderer/src/components/ContextMenu.tsx | 25 ++++++++- src/renderer/src/components/DiffView.test.tsx | 2 +- src/renderer/src/components/DiffView.tsx | 8 +-- src/renderer/src/components/Toolbar.tsx | 7 +-- src/renderer/src/diff/diffEngine.ts | 4 +- 13 files changed, 158 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index cd84b9b..0429127 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 -- **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开 +- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 - **滚动联动**:左右两个面板滚动位置自动同步 --- @@ -90,7 +90,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.2.4 +- 版本:0.2.5 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 4b33737..6d03bab 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 5c39638..516ddb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a2c7f5e..13a0946 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.2.4", + "version": "0.2.5", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 8b2f15e..389e903 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 096c09c..8f8389e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( @@ -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) }} >
@@ -93,7 +88,7 @@ function EmptyPane({ export default function App(): ReactElement { const [paneL, setPaneL] = useState(null) const [paneR, setPaneR] = useState(null) - const [options, setOptions] = useState({ trimWhitespace: false, ignoreCase: false }) + const [options, setOptions] = useState({ trimWhitespace: false, ignoreCase: false }) const [activeRowId, setActiveRowId] = useState(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 diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index f7dd487..c56541c 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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 + function mockApi(overrides?: Partial): 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 { 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 - 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() 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() + 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() + 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() + 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() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.test.tsx b/src/renderer/src/components/ContextMenu.test.tsx index f91ec73..08c60d5 100644 --- a/src/renderer/src/components/ContextMenu.test.tsx +++ b/src/renderer/src/components/ContextMenu.test.tsx @@ -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( + + ) + 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( + + ) + const menu = document.querySelector('.ctx-menu') as HTMLElement + expect(menu.style.left).toBe('10px') + expect(menu.style.top).toBe('10px') + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx index 5773200..c781bff 100644 --- a/src/renderer/src/components/ContextMenu.tsx +++ b/src/renderer/src/components/ContextMenu.tsx @@ -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(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 (
e.stopPropagation()} > {items.map((it, i) => ( @@ -60,4 +79,4 @@ export default function ContextMenu({
) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/DiffView.test.tsx b/src/renderer/src/components/DiffView.test.tsx index 1872a2d..7c8638a 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -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]) }) }) \ No newline at end of file diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index 7da2b0c..f0e3b50 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -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 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) }} >
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index f6994f8..3a38b8a 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -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: '纯文本' }, diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index c35bb75..885dc7c 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -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)