feat: v0.2.2 体验打磨 - 拖拽处处可用、手动粘贴对比、仅限文本文件、报告与可读性增强

This commit is contained in:
2026-08-17 18:38:37 +08:00
parent 05f05a29ee
commit f5aefbbe00
14 changed files with 483 additions and 42 deletions
+2 -1
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+103 -24
View File
@@ -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 }) => {
window.setTimeout(() => setToast(null), 3600) setToast({ text, action })
}, []) 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,21 +112,28 @@ export default function App(): ReactElement {
else setPaneR(pane) else setPaneR(pane)
}, []) }, [])
const dropPane = useCallback(async (side: 'left' | 'right', file: File) => { const dropPane = useCallback(
let text = '' async (side: 'left' | 'right', file: File) => {
let encoding = 'UTF-8' if (!isTextFile(file.name)) {
try { showToast(`仅支持文本文件:${file.name}`)
const buf = await file.arrayBuffer() return
const decoded = await window.api.decodeBuffer(buf) }
text = decoded.text let text = ''
encoding = decoded.encoding let encoding = 'UTF-8'
} catch { try {
text = await file.text().catch(() => '') const buf = await file.arrayBuffer()
} const decoded = await window.api.decodeBuffer(buf)
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text } text = decoded.text
if (side === 'left') setPaneL(pane) encoding = decoded.encoding
else setPaneR(pane) } 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(() => { 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>
) )
} }
+40 -2
View File
@@ -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)
})
}) })
+30 -3
View 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">&#8203;</span> return <span className="plain-empty">&#8203;</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>
)
}
+10 -2
View File
@@ -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}`
] ]
+33
View File
@@ -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')
})
})
+74
View File
@@ -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)
}
+72
View File
@@ -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;