feat: v0.2.2 体验打磨 - 拖拽处处可用、手动粘贴对比、仅限文本文件、报告与可读性增强
This commit is contained in:
+2
-1
@@ -57,7 +57,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.1.0 ← 初始版本(已发布)
|
0.1.0 ← 初始版本(已发布)
|
||||||
0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
||||||
0.2.0 ← 导出差异报告(已发布):HTML / 纯文本 / Markdown 三种格式
|
0.2.0 ← 导出差异报告(已发布):HTML / 纯文本 / Markdown 三种格式
|
||||||
0.2.1 ← 建立测试体系(当前):Vitest 单元+组件测试 · 测试策略文档
|
0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档
|
||||||
|
0.2.2 ← 体验打磨(当前):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件
|
||||||
0.3.0 ← 新增功能
|
0.3.0 ← 新增功能
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
@@ -3471,7 +3471,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chromium-pickle-js": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz",
|
||||||
"integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
|
"integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4229,7 +4229,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5481,7 +5481,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
|
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6369,7 +6369,7 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/node-api-version": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz",
|
||||||
"integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
|
"integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8405,7 +8405,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yocto-queue": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+1
-2
@@ -71,8 +71,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
|
|||||||
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
|
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [
|
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: ['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'] }
|
||||||
{ name: '所有文件', extensions: ['*'] }
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
if (result.canceled || result.filePaths.length === 0) return null
|
if (result.canceled || result.filePaths.length === 0) return null
|
||||||
|
|||||||
+89
-10
@@ -11,7 +11,9 @@ import type { DiffResult, DiffSummary } from './diff/diffEngine'
|
|||||||
import DiffView from './components/DiffView'
|
import DiffView from './components/DiffView'
|
||||||
import Toolbar from './components/Toolbar'
|
import Toolbar from './components/Toolbar'
|
||||||
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
||||||
|
import TextInputModal from './components/TextInputModal'
|
||||||
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
|
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
|
||||||
|
import { isTextFile } from './diff/textUtils'
|
||||||
import type { PaneMeta } from './components/DiffView'
|
import type { PaneMeta } from './components/DiffView'
|
||||||
import type { SideCell } from './diff/diffEngine'
|
import type { SideCell } from './diff/diffEngine'
|
||||||
|
|
||||||
@@ -90,12 +92,17 @@ export default function App(): ReactElement {
|
|||||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||||
const [navIndex, setNavIndex] = useState(0)
|
const [navIndex, setNavIndex] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
||||||
const [toast, setToast] = useState<string | null>(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) => {
|
const showToast = useCallback(
|
||||||
setToast(text)
|
(text: string, action?: { label: string; onClick: () => void }) => {
|
||||||
|
setToast({ text, action })
|
||||||
window.setTimeout(() => setToast(null), 3600)
|
window.setTimeout(() => setToast(null), 3600)
|
||||||
}, [])
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const openPane = useCallback(async (side: 'left' | 'right') => {
|
const openPane = useCallback(async (side: 'left' | 'right') => {
|
||||||
const data = await window.api.openFile(side)
|
const data = await window.api.openFile(side)
|
||||||
@@ -105,7 +112,12 @@ export default function App(): ReactElement {
|
|||||||
else setPaneR(pane)
|
else setPaneR(pane)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dropPane = useCallback(async (side: 'left' | 'right', file: File) => {
|
const dropPane = useCallback(
|
||||||
|
async (side: 'left' | 'right', file: File) => {
|
||||||
|
if (!isTextFile(file.name)) {
|
||||||
|
showToast(`仅支持文本文件:${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
let text = ''
|
let text = ''
|
||||||
let encoding = 'UTF-8'
|
let encoding = 'UTF-8'
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +131,9 @@ export default function App(): ReactElement {
|
|||||||
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
|
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
|
||||||
if (side === 'left') setPaneL(pane)
|
if (side === 'left') setPaneL(pane)
|
||||||
else setPaneR(pane)
|
else setPaneR(pane)
|
||||||
}, [])
|
},
|
||||||
|
[showToast]
|
||||||
|
)
|
||||||
|
|
||||||
const diff: DiffResult = useMemo(() => {
|
const diff: DiffResult = useMemo(() => {
|
||||||
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||||
@@ -181,18 +195,33 @@ export default function App(): ReactElement {
|
|||||||
leftName: paneL?.meta.name ?? '',
|
leftName: paneL?.meta.name ?? '',
|
||||||
rightName: paneR?.meta.name ?? '',
|
rightName: paneR?.meta.name ?? '',
|
||||||
leftEncoding: paneL?.meta.encoding ?? '',
|
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 content = buildReport(diff.rows, ctx, summary, fmt)
|
||||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||||
const res = await window.api.saveReport(content, defaultName)
|
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]
|
[diff, paneL, paneR, summary, showToast]
|
||||||
)
|
)
|
||||||
|
|
||||||
const anyPane = paneL !== null || paneR !== null
|
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 leftMeta = paneL?.meta ?? null
|
||||||
const rightMeta = paneR?.meta ?? null
|
const rightMeta = paneR?.meta ?? null
|
||||||
|
|
||||||
@@ -215,6 +244,33 @@ export default function App(): ReactElement {
|
|||||||
<button className="btn ghost" onClick={() => void openPane('right')}>
|
<button className="btn ghost" onClick={() => void openPane('right')}>
|
||||||
打开右侧
|
打开右侧
|
||||||
</button>
|
</button>
|
||||||
|
<div className="tool-group export-wrap">
|
||||||
|
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
|
||||||
|
粘贴文本
|
||||||
|
</button>
|
||||||
|
{pasteOpen && (
|
||||||
|
<div className="export-menu">
|
||||||
|
<button
|
||||||
|
className="ctx-item"
|
||||||
|
onClick={() => {
|
||||||
|
setPasteOpen(false)
|
||||||
|
setPasteSide('left')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
粘贴到左侧
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ctx-item"
|
||||||
|
onClick={() => {
|
||||||
|
setPasteOpen(false)
|
||||||
|
setPasteSide('right')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
粘贴到右侧
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn ghost"
|
className="btn ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -235,6 +291,7 @@ export default function App(): ReactElement {
|
|||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
onOpen={(s) => void openPane(s)}
|
onOpen={(s) => void openPane(s)}
|
||||||
onRowContext={onRowContext}
|
onRowContext={onRowContext}
|
||||||
|
onDropFile={(s, f) => void dropPane(s, f)}
|
||||||
/>
|
/>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
options={options}
|
options={options}
|
||||||
@@ -269,7 +326,7 @@ export default function App(): ReactElement {
|
|||||||
<div className="status-spacer" />
|
<div className="status-spacer" />
|
||||||
<div className="status-right">
|
<div className="status-right">
|
||||||
{summary.changedLines === 0 ? (
|
{summary.changedLines === 0 ? (
|
||||||
<span className="eq-badge same">内容一致</span>
|
<span className="eq-badge same">两文件内容完全一致</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="eq-badge diff">有差异</span>
|
<span className="eq-badge diff">有差异</span>
|
||||||
)}
|
)}
|
||||||
@@ -277,7 +334,29 @@ export default function App(): ReactElement {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{pasteSide && (
|
||||||
|
<TextInputModal
|
||||||
|
title={`粘贴${pasteSide === 'left' ? '左侧' : '右侧'}文本`}
|
||||||
|
onCancel={() => setPasteSide(null)}
|
||||||
|
onConfirm={(t) => confirmPaste(pasteSide, t)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toast && (
|
||||||
|
<div className="toast">
|
||||||
|
<span className="toast-text">{toast.text}</span>
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
className="toast-btn"
|
||||||
|
onClick={() => {
|
||||||
|
toast.action!.onClick()
|
||||||
|
setToast(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -69,9 +69,11 @@ describe('App - 加载文件后的非空态', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('App - 导出报告交互', () => {
|
describe('App - 导出报告交互', () => {
|
||||||
it('导出 HTML 成功后弹出保存提示(覆盖 exportReport 成功分支)', async () => {
|
it('导出 HTML 成功后弹保存提示,可一键打开文件夹', async () => {
|
||||||
|
const showInFolder = vi.fn(async () => true)
|
||||||
window.api = mockApi({
|
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(<App />)
|
render(<App />)
|
||||||
fireEvent.click(screen.getByText('打开左侧'))
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
@@ -79,6 +81,8 @@ describe('App - 导出报告交互', () => {
|
|||||||
fireEvent.click(screen.getByText('导出报告'))
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
fireEvent.click(screen.getByText('HTML 报告'))
|
fireEvent.click(screen.getByText('HTML 报告'))
|
||||||
expect(await screen.findByText(/报告已保存/)).toBeInTheDocument()
|
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] } })
|
fireEvent.drop(pane, { dataTransfer: { files: [file] } })
|
||||||
expect(await screen.findByText('导出报告')).toBeInTheDocument()
|
expect(await screen.findByText('导出报告')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('拖放非文本文件被拒绝并提示', async () => {
|
||||||
|
render(<App />)
|
||||||
|
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(<App />)
|
||||||
|
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(<App />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -25,6 +25,7 @@ function renderDiff(rows: DiffRow[], activeRowId: string | null = null) {
|
|||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
onOpen={vi.fn()}
|
onOpen={vi.fn()}
|
||||||
onRowContext={vi.fn()}
|
onRowContext={vi.fn()}
|
||||||
|
onDropFile={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,7 @@ describe('DiffView', () => {
|
|||||||
activeRowId={null}
|
activeRowId={null}
|
||||||
onOpen={vi.fn()}
|
onOpen={vi.fn()}
|
||||||
onRowContext={onRowContext}
|
onRowContext={onRowContext}
|
||||||
|
onDropFile={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.contextMenu(screen.getAllByText('hello')[0])
|
fireEvent.contextMenu(screen.getAllByText('hello')[0])
|
||||||
@@ -146,9 +148,47 @@ describe('DiffView', () => {
|
|||||||
activeRowId={null}
|
activeRowId={null}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRowContext={vi.fn()}
|
onRowContext={vi.fn()}
|
||||||
|
onDropFile={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getAllByText('重新选择')[0])
|
fireEvent.click(screen.getAllByText('重新选择')[0])
|
||||||
expect(onOpen).toHaveBeenCalledWith('left')
|
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(
|
||||||
|
<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 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -20,6 +20,20 @@ interface DiffViewProps {
|
|||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
onOpen: (side: 'left' | 'right') => void
|
onOpen: (side: 'left' | 'right') => void
|
||||||
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => 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,
|
||||||
|
<span className="ws" key="ws">
|
||||||
|
{m[1]}
|
||||||
|
</span>
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCellText(cell: SideCell): ReactNode {
|
function renderCellText(cell: SideCell): ReactNode {
|
||||||
@@ -33,7 +47,7 @@ function renderCellText(cell: SideCell): ReactNode {
|
|||||||
if (cell.text === null) {
|
if (cell.text === null) {
|
||||||
return <span className="plain-empty">​</span>
|
return <span className="plain-empty">​</span>
|
||||||
}
|
}
|
||||||
return <span className="plain">{cell.text}</span>
|
return <span className="plain">{renderWithWs(cell.text)}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidePanel({
|
function SidePanel({
|
||||||
@@ -42,6 +56,7 @@ function SidePanel({
|
|||||||
meta,
|
meta,
|
||||||
onOpen,
|
onOpen,
|
||||||
onRowContext,
|
onRowContext,
|
||||||
|
onDropFile,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
activeRowId
|
activeRowId
|
||||||
@@ -51,12 +66,21 @@ function SidePanel({
|
|||||||
meta: PaneMeta | null
|
meta: PaneMeta | null
|
||||||
onOpen: (side: 'left' | 'right') => void
|
onOpen: (side: 'left' | 'right') => void
|
||||||
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
||||||
|
onDropFile: (side: 'left' | 'right', file: File) => void
|
||||||
scrollRef: RefObject<HTMLDivElement>
|
scrollRef: RefObject<HTMLDivElement>
|
||||||
onScroll: () => void
|
onScroll: () => void
|
||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className={'pane pane-mid pane-' + side}>
|
<div
|
||||||
|
className={'pane pane-mid pane-' + side}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const f = e.dataTransfer.files[0]
|
||||||
|
if (f) onDropFile(side, f)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<span className={'pane-dot ' + side} />
|
<span className={'pane-dot ' + side} />
|
||||||
<span className="pane-file">{meta ? meta.name : '(未选择)'}</span>
|
<span className="pane-file">{meta ? meta.name : '(未选择)'}</span>
|
||||||
@@ -97,7 +121,8 @@ export default function DiffView({
|
|||||||
rightMeta,
|
rightMeta,
|
||||||
activeRowId,
|
activeRowId,
|
||||||
onOpen,
|
onOpen,
|
||||||
onRowContext
|
onRowContext,
|
||||||
|
onDropFile
|
||||||
}: DiffViewProps): ReactElement {
|
}: DiffViewProps): ReactElement {
|
||||||
const leftRef = useRef<HTMLDivElement>(null)
|
const leftRef = useRef<HTMLDivElement>(null)
|
||||||
const rightRef = useRef<HTMLDivElement>(null)
|
const rightRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -132,6 +157,7 @@ export default function DiffView({
|
|||||||
meta={leftMeta}
|
meta={leftMeta}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRowContext={onRowContext}
|
onRowContext={onRowContext}
|
||||||
|
onDropFile={onDropFile}
|
||||||
scrollRef={leftRef}
|
scrollRef={leftRef}
|
||||||
onScroll={makeScrollSync('left')}
|
onScroll={makeScrollSync('left')}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
@@ -142,6 +168,7 @@ export default function DiffView({
|
|||||||
meta={rightMeta}
|
meta={rightMeta}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRowContext={onRowContext}
|
onRowContext={onRowContext}
|
||||||
|
onDropFile={onDropFile}
|
||||||
scrollRef={rightRef}
|
scrollRef={rightRef}
|
||||||
onScroll={makeScrollSync('right')}
|
onScroll={makeScrollSync('right')}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
|
|||||||
@@ -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(<TextInputModal title="粘贴左侧文本" onCancel={onCancel} onConfirm={onConfirm} />)
|
||||||
|
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(
|
||||||
|
<TextInputModal title="粘贴左侧文本" onCancel={onCancel} onConfirm={vi.fn()} />
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('取消'))
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
onMouseDown={onCancel}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onCancel()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="text-modal-head">{title}</div>
|
||||||
|
<textarea
|
||||||
|
className="text-modal-input"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
placeholder="在此粘贴或输入文本,然后点击“开始对比”…"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="text-modal-actions">
|
||||||
|
<button className="btn" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={() => onConfirm(text)}>
|
||||||
|
开始对比
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ export interface ReportContext {
|
|||||||
leftEncoding: string
|
leftEncoding: string
|
||||||
/** 右侧编码标注 */
|
/** 右侧编码标注 */
|
||||||
rightEncoding: string
|
rightEncoding: string
|
||||||
|
/** 左侧文件完整路径(可选) */
|
||||||
|
leftPath?: string
|
||||||
|
/** 右侧文件完整路径(可选) */
|
||||||
|
rightPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
@@ -43,8 +47,12 @@ const headLines = (
|
|||||||
title: string
|
title: string
|
||||||
): string[] => [
|
): string[] => [
|
||||||
title,
|
title,
|
||||||
`对比:${ctx.leftName || '(未选择)'}${ctx.leftEncoding ? ` (${ctx.leftEncoding})` : ''} → ${ctx.rightName || '(未选择)'}${ctx.rightEncoding ? ` (${ctx.rightEncoding})` : ''}`,
|
`对比:${ctx.leftName || '(未选择)'}${ctx.leftEncoding ? ` (${ctx.leftEncoding})` : ''}${
|
||||||
`生成时间:${new Date().toLocaleString('zh-CN')}`,
|
ctx.leftPath ? ` [${ctx.leftPath}]` : ''
|
||||||
|
} → ${ctx.rightName || '(未选择)'}${ctx.rightEncoding ? ` (${ctx.rightEncoding})` : ''}${
|
||||||
|
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
||||||
|
}`,
|
||||||
|
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { TEXT_EXTENSIONS, isTextFile } from './textUtils'
|
||||||
|
|
||||||
|
describe('textUtils', () => {
|
||||||
|
it('常见文本扩展名放行', () => {
|
||||||
|
expect(isTextFile('a.json')).toBe(true)
|
||||||
|
expect(isTextFile('b.ts')).toBe(true)
|
||||||
|
expect(isTextFile('c.md')).toBe(true)
|
||||||
|
expect(isTextFile('d.log')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无扩展名文件按文本放行', () => {
|
||||||
|
expect(isTextFile('README')).toBe(true)
|
||||||
|
expect(isTextFile('Makefile')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非文本扩展名拒绝', () => {
|
||||||
|
expect(isTextFile('img.png')).toBe(false)
|
||||||
|
expect(isTextFile('a.exe')).toBe(false)
|
||||||
|
expect(isTextFile('b.zip')).toBe(false)
|
||||||
|
expect(isTextFile('c.jpg')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('扩展名大小写不敏感', () => {
|
||||||
|
expect(isTextFile('A.TXT')).toBe(true)
|
||||||
|
expect(isTextFile('CODE.MD')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文本扩展名清单非空', () => {
|
||||||
|
expect(TEXT_EXTENSIONS.length).toBeGreaterThan(0)
|
||||||
|
expect(TEXT_EXTENSIONS).toContain('txt')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** 允许打开/拖拽的文本类扩展名(小写,不含点) */
|
||||||
|
export const TEXT_EXTENSIONS: string[] = [
|
||||||
|
'txt',
|
||||||
|
'text',
|
||||||
|
'md',
|
||||||
|
'markdown',
|
||||||
|
'json',
|
||||||
|
'js',
|
||||||
|
'mjs',
|
||||||
|
'cjs',
|
||||||
|
'ts',
|
||||||
|
'mts',
|
||||||
|
'cts',
|
||||||
|
'tsx',
|
||||||
|
'jsx',
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'less',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'xml',
|
||||||
|
'yml',
|
||||||
|
'yaml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'properties',
|
||||||
|
'env',
|
||||||
|
'py',
|
||||||
|
'java',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'cpp',
|
||||||
|
'hpp',
|
||||||
|
'cc',
|
||||||
|
'cs',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'rb',
|
||||||
|
'php',
|
||||||
|
'pl',
|
||||||
|
'sh',
|
||||||
|
'zsh',
|
||||||
|
'bash',
|
||||||
|
'fish',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'ps1',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
|
'sql',
|
||||||
|
'vue',
|
||||||
|
'svelte',
|
||||||
|
'graphql',
|
||||||
|
'gql',
|
||||||
|
'gradle',
|
||||||
|
'lock'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断文件名是否为允许的文本文件。
|
||||||
|
* 无扩展名的文件按文本尝试放行;有扩展名但不在文本列表内则视为非文本。
|
||||||
|
*/
|
||||||
|
export function isTextFile(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
if (dot < 0) return true
|
||||||
|
const ext = name.slice(dot + 1).toLowerCase()
|
||||||
|
return TEXT_EXTENSIONS.includes(ext)
|
||||||
|
}
|
||||||
@@ -597,6 +597,78 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 行尾空白可视标注 ============ */
|
||||||
|
.ws {
|
||||||
|
background: rgba(34, 211, 238, 0.22);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(34, 211, 238, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ toast 动作按钮 ============ */
|
||||||
|
.toast-text {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.toast-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #6a4dff);
|
||||||
|
box-shadow: 0 0 12px rgba(124, 108, 255, 0.4);
|
||||||
|
}
|
||||||
|
.toast-btn:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 粘贴文本弹窗 ============ */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(4, 7, 12, 0.55);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.text-modal {
|
||||||
|
width: min(620px, 90vw);
|
||||||
|
background: rgba(16, 22, 34, 0.98);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(124, 108, 255, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
.text-modal-head {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(124, 108, 255, 0.12), transparent);
|
||||||
|
}
|
||||||
|
.text-modal-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.text-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 状态栏 ============ */
|
/* ============ 状态栏 ============ */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user