diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 71ce7c3..2527726 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -57,7 +57,8 @@ z = 补丁版本号(Patch) 0.1.0 ← 初始版本(已发布) 0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正 0.2.0 ← 导出差异报告(已发布):HTML / 纯文本 / Markdown 三种格式 -0.2.1 ← 建立测试体系(当前):Vitest 单元+组件测试 · 测试策略文档 +0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档 +0.2.2 ← 体验打磨(当前):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件 0.3.0 ← 新增功能 ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/package-lock.json b/package-lock.json index 07b036e..30d74c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -3471,7 +3471,7 @@ } }, "node_modules/chromium-pickle-js": { - "version": "0.2.1", + "version": "0.2.2", "resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true, @@ -4229,7 +4229,7 @@ } }, "node_modules/eastasianwidth": { - "version": "0.2.1", + "version": "0.2.2", "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, @@ -5481,7 +5481,7 @@ "license": "MIT" }, "node_modules/is-unicode-supported": { - "version": "0.2.1", + "version": "0.2.2", "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, @@ -6369,7 +6369,7 @@ "optional": true }, "node_modules/node-api-version": { - "version": "0.2.1", + "version": "0.2.2", "resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", "dev": true, @@ -8405,7 +8405,7 @@ } }, "node_modules/yocto-queue": { - "version": "0.2.1", + "version": "0.2.2", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, diff --git a/package.json b/package.json index 171b778..6b89b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.2.1", + "version": "0.2.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index c59e126..c2db911 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -71,8 +71,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, properties: ['openFile'], filters: [ - { name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'xml', 'yml', 'yaml', 'py', 'java', 'go', 'rs', 'c', 'cpp', 'h', 'log', 'csv', 'sql'] }, - { name: '所有文件', extensions: ['*'] } + { name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'swift', 'kt', 'rb', 'php', 'sh', 'bat', 'ps1', 'log', 'csv', 'sql', 'vue', 'graphql', 'gradle'] } ] }) if (result.canceled || result.filePaths.length === 0) return null diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 02a3819..b4fcff1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,7 +11,9 @@ import type { DiffResult, DiffSummary } from './diff/diffEngine' import DiffView from './components/DiffView' import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' +import TextInputModal from './components/TextInputModal' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' +import { isTextFile } from './diff/textUtils' import type { PaneMeta } from './components/DiffView' import type { SideCell } from './diff/diffEngine' @@ -90,12 +92,17 @@ export default function App(): ReactElement { const [activeRowId, setActiveRowId] = useState(null) const [navIndex, setNavIndex] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) - const [toast, setToast] = useState(null) + const [toast, setToast] = useState<{ text: string; action?: { label: string; onClick: () => void } } | null>(null) + const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null) + const [pasteOpen, setPasteOpen] = useState(false) - const showToast = useCallback((text: string) => { - setToast(text) - window.setTimeout(() => setToast(null), 3600) - }, []) + const showToast = useCallback( + (text: string, action?: { label: string; onClick: () => void }) => { + setToast({ text, action }) + window.setTimeout(() => setToast(null), 3600) + }, + [] + ) const openPane = useCallback(async (side: 'left' | 'right') => { const data = await window.api.openFile(side) @@ -105,21 +112,28 @@ export default function App(): ReactElement { else setPaneR(pane) }, []) - const dropPane = useCallback(async (side: 'left' | 'right', file: File) => { - let text = '' - let encoding = 'UTF-8' - try { - const buf = await file.arrayBuffer() - const decoded = await window.api.decodeBuffer(buf) - text = decoded.text - encoding = decoded.encoding - } catch { - text = await file.text().catch(() => '') - } - const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text } - if (side === 'left') setPaneL(pane) - else setPaneR(pane) - }, []) + const dropPane = useCallback( + async (side: 'left' | 'right', file: File) => { + if (!isTextFile(file.name)) { + showToast(`仅支持文本文件:${file.name}`) + return + } + let text = '' + let encoding = 'UTF-8' + try { + const buf = await file.arrayBuffer() + const decoded = await window.api.decodeBuffer(buf) + text = decoded.text + encoding = decoded.encoding + } catch { + text = await file.text().catch(() => '') + } + const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text } + if (side === 'left') setPaneL(pane) + else setPaneR(pane) + }, + [showToast] + ) const diff: DiffResult = useMemo(() => { return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options) @@ -181,18 +195,33 @@ export default function App(): ReactElement { leftName: paneL?.meta.name ?? '', rightName: paneR?.meta.name ?? '', leftEncoding: paneL?.meta.encoding ?? '', - rightEncoding: paneR?.meta.encoding ?? '' + rightEncoding: paneR?.meta.encoding ?? '', + leftPath: paneL?.path || undefined, + rightPath: paneR?.path || undefined } const content = buildReport(diff.rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` const res = await window.api.saveReport(content, defaultName) - if (res.ok && res.path) showToast(`报告已保存:${res.path}`) + if (res.ok && res.path) { + const p = res.path + showToast(`报告已保存:${p}`, { + label: '打开文件夹', + onClick: () => void window.api.showInFolder(p) + }) + } }, [diff, paneL, paneR, summary, showToast] ) 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 leftMeta = paneL?.meta ?? null const rightMeta = paneR?.meta ?? null @@ -215,6 +244,33 @@ export default function App(): ReactElement { +
+ + {pasteOpen && ( +
+ + +
+ )} +
+ )} + + )} ) } \ No newline at end of file diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 5789b4c..26f98df 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -69,9 +69,11 @@ describe('App - 加载文件后的非空态', () => { }) describe('App - 导出报告交互', () => { - it('导出 HTML 成功后弹出保存提示(覆盖 exportReport 成功分支)', async () => { + it('导出 HTML 成功后弹保存提示,可一键打开文件夹', async () => { + const showInFolder = vi.fn(async () => true) window.api = mockApi({ - saveReport: async () => ({ ok: true, path: 'C:/docs/DiffLens-report.html' }) + saveReport: async () => ({ ok: true, path: 'C:/docs/DiffLens-report.html' }), + showInFolder }) render() fireEvent.click(screen.getByText('打开左侧')) @@ -79,6 +81,8 @@ describe('App - 导出报告交互', () => { fireEvent.click(screen.getByText('导出报告')) fireEvent.click(screen.getByText('HTML 报告')) expect(await screen.findByText(/报告已保存/)).toBeInTheDocument() + fireEvent.click(screen.getByText('打开文件夹')) + expect(showInFolder).toHaveBeenCalledWith('C:/docs/DiffLens-report.html') }) }) @@ -210,4 +214,38 @@ describe('App - 右侧面板交互', () => { fireEvent.drop(pane, { dataTransfer: { files: [file] } }) expect(await screen.findByText('导出报告')).toBeInTheDocument() }) + + it('拖放非文本文件被拒绝并提示', async () => { + render() + const file = new File(['fake'], 'img.png', { type: 'image/png' }) + const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element + fireEvent.drop(pane, { dataTransfer: { files: [file] } }) + expect(await screen.findByText(/仅支持文本文件/)).toBeInTheDocument() + expect(screen.queryByText('导出报告')).not.toBeInTheDocument() + }) +}) + +describe('App - 手动粘贴文本', () => { + it('粘贴文本到左侧并开始对比', async () => { + render() + fireEvent.click(screen.getByText('粘贴文本')) + fireEvent.click(screen.getByText('粘贴到左侧')) + const ta = screen.getByPlaceholderText(/粘贴或输入/) + fireEvent.change(ta, { target: { value: 'alpha\nbeta' } }) + fireEvent.click(screen.getByText('开始对比')) + expect(await screen.findByText('导出报告')).toBeInTheDocument() + expect(screen.getAllByText('手动文本').length).toBeGreaterThan(0) + }) + + it('粘贴到右侧入口可用', async () => { + render() + fireEvent.click(screen.getByText('粘贴文本')) + fireEvent.click(screen.getByText('粘贴到右侧')) + expect(screen.getByText('粘贴右侧文本')).toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText(/粘贴或输入/), { + target: { value: 'x' } + }) + fireEvent.click(screen.getByText('开始对比')) + expect(await screen.findByText('导出报告')).toBeInTheDocument() + }) }) \ 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 d509fa8..f465873 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -25,6 +25,7 @@ function renderDiff(rows: DiffRow[], activeRowId: string | null = null) { activeRowId={activeRowId} onOpen={vi.fn()} onRowContext={vi.fn()} + onDropFile={vi.fn()} /> ) } @@ -127,6 +128,7 @@ describe('DiffView', () => { activeRowId={null} onOpen={vi.fn()} onRowContext={onRowContext} + onDropFile={vi.fn()} /> ) fireEvent.contextMenu(screen.getAllByText('hello')[0]) @@ -146,9 +148,47 @@ describe('DiffView', () => { activeRowId={null} onOpen={onOpen} onRowContext={vi.fn()} + onDropFile={vi.fn()} /> ) fireEvent.click(screen.getAllByText('重新选择')[0]) expect(onOpen).toHaveBeenCalledWith('left') }) + + it('渲染行尾空白可视标注', () => { + const row: DiffRow = { + id: 'w0', + rowKind: 'unchanged', + isChanged: false, + left: { lineNo: 1, text: 'pad ', segs: null }, + right: { lineNo: 1, text: 'pad ', segs: null } + } + const { container } = renderDiff([row]) + expect(container.querySelector('.ws')).toBeTruthy() + }) + + it('拖放文件到面板触发 onDropFile', () => { + const onDropFile = vi.fn() + const { container } = (() => { + const utils = render( + + ) + return utils + })() + 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) + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index e0811a7..e5010b7 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -20,6 +20,20 @@ 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 +} + +/** 把行尾空白用淡色高亮显示,便于识别被忽略/忽略差异 */ +function renderWithWs(text: string): ReactNode[] { + const m = /(\s+)$/.exec(text) + if (!m || m[1].length === 0) return [text] + const body = text.slice(0, text.length - m[1].length) + return [ + body, + + {m[1]} + + ] } function renderCellText(cell: SideCell): ReactNode { @@ -33,7 +47,7 @@ function renderCellText(cell: SideCell): ReactNode { if (cell.text === null) { return } - return {cell.text} + return {renderWithWs(cell.text)} } function SidePanel({ @@ -42,6 +56,7 @@ function SidePanel({ meta, onOpen, onRowContext, + onDropFile, scrollRef, onScroll, activeRowId @@ -51,12 +66,21 @@ 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 scrollRef: RefObject onScroll: () => void activeRowId: string | null }): ReactElement { return ( -
+
e.preventDefault()} + onDrop={(e) => { + e.preventDefault() + const f = e.dataTransfer.files[0] + if (f) onDropFile(side, f) + }} + >
{meta ? meta.name : '(未选择)'} @@ -97,7 +121,8 @@ export default function DiffView({ rightMeta, activeRowId, onOpen, - onRowContext + onRowContext, + onDropFile }: DiffViewProps): ReactElement { const leftRef = useRef(null) const rightRef = useRef(null) @@ -132,6 +157,7 @@ export default function DiffView({ meta={leftMeta} onOpen={onOpen} onRowContext={onRowContext} + onDropFile={onDropFile} scrollRef={leftRef} onScroll={makeScrollSync('left')} activeRowId={activeRowId} @@ -142,6 +168,7 @@ export default function DiffView({ meta={rightMeta} onOpen={onOpen} onRowContext={onRowContext} + onDropFile={onDropFile} scrollRef={rightRef} onScroll={makeScrollSync('right')} activeRowId={activeRowId} diff --git a/src/renderer/src/components/TextInputModal.test.tsx b/src/renderer/src/components/TextInputModal.test.tsx new file mode 100644 index 0000000..9318039 --- /dev/null +++ b/src/renderer/src/components/TextInputModal.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import TextInputModal from './TextInputModal' + +describe('TextInputModal', () => { + it('输入文本并确认提交', () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + render() + const ta = screen.getByPlaceholderText(/粘贴或输入/) + fireEvent.change(ta, { target: { value: 'hello\nworld' } }) + fireEvent.click(screen.getByText('开始对比')) + expect(onConfirm).toHaveBeenCalledWith('hello\nworld') + }) + + it('点击取消触发 onCancel', () => { + const onCancel = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('取消')) + expect(onCancel).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/renderer/src/components/TextInputModal.tsx b/src/renderer/src/components/TextInputModal.tsx new file mode 100644 index 0000000..34ac3e6 --- /dev/null +++ b/src/renderer/src/components/TextInputModal.tsx @@ -0,0 +1,46 @@ +import { useState, type ReactElement } from 'react' + +interface TextInputModalProps { + title: string + onCancel: () => void + onConfirm: (text: string) => void +} + +export default function TextInputModal({ + title, + onCancel, + onConfirm +}: TextInputModalProps): ReactElement { + const [text, setText] = useState('') + + return ( +
{ + e.preventDefault() + onCancel() + }} + > +
e.stopPropagation()}> +
{title}
+