feat: v0.2.2 体验打磨 - 拖拽处处可用、手动粘贴对比、仅限文本文件、报告与可读性增强
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
Generated
+7
-7
@@ -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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+89
-10
@@ -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<string | null>(null)
|
||||
const [navIndex, setNavIndex] = useState(0)
|
||||
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) => {
|
||||
setToast(text)
|
||||
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,7 +112,12 @@ export default function App(): ReactElement {
|
||||
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 encoding = 'UTF-8'
|
||||
try {
|
||||
@@ -119,7 +131,9 @@ export default function App(): ReactElement {
|
||||
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 {
|
||||
<button className="btn ghost" onClick={() => void openPane('right')}>
|
||||
打开右侧
|
||||
</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
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
@@ -235,6 +291,7 @@ export default function App(): ReactElement {
|
||||
activeRowId={activeRowId}
|
||||
onOpen={(s) => void openPane(s)}
|
||||
onRowContext={onRowContext}
|
||||
onDropFile={(s, f) => void dropPane(s, f)}
|
||||
/>
|
||||
<Toolbar
|
||||
options={options}
|
||||
@@ -269,7 +326,7 @@ export default function App(): ReactElement {
|
||||
<div className="status-spacer" />
|
||||
<div className="status-right">
|
||||
{summary.changedLines === 0 ? (
|
||||
<span className="eq-badge same">内容一致</span>
|
||||
<span className="eq-badge same">两文件内容完全一致</span>
|
||||
) : (
|
||||
<span className="eq-badge diff">有差异</span>
|
||||
)}
|
||||
@@ -277,7 +334,29 @@ export default function App(): ReactElement {
|
||||
</footer>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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(<App />)
|
||||
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(<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}
|
||||
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(
|
||||
<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
|
||||
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,
|
||||
<span className="ws" key="ws">
|
||||
{m[1]}
|
||||
</span>
|
||||
]
|
||||
}
|
||||
|
||||
function renderCellText(cell: SideCell): ReactNode {
|
||||
@@ -33,7 +47,7 @@ function renderCellText(cell: SideCell): ReactNode {
|
||||
if (cell.text === null) {
|
||||
return <span className="plain-empty">​</span>
|
||||
}
|
||||
return <span className="plain">{cell.text}</span>
|
||||
return <span className="plain">{renderWithWs(cell.text)}</span>
|
||||
}
|
||||
|
||||
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<HTMLDivElement>
|
||||
onScroll: () => void
|
||||
activeRowId: string | null
|
||||
}): ReactElement {
|
||||
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">
|
||||
<span className={'pane-dot ' + side} />
|
||||
<span className="pane-file">{meta ? meta.name : '(未选择)'}</span>
|
||||
@@ -97,7 +121,8 @@ export default function DiffView({
|
||||
rightMeta,
|
||||
activeRowId,
|
||||
onOpen,
|
||||
onRowContext
|
||||
onRowContext,
|
||||
onDropFile
|
||||
}: DiffViewProps): ReactElement {
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(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}
|
||||
|
||||
@@ -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
|
||||
/** 右侧编码标注 */
|
||||
rightEncoding: string
|
||||
/** 左侧文件完整路径(可选) */
|
||||
leftPath?: string
|
||||
/** 右侧文件完整路径(可选) */
|
||||
rightPath?: string
|
||||
}
|
||||
|
||||
const esc = (s: string): string =>
|
||||
@@ -43,8 +47,12 @@ const headLines = (
|
||||
title: string
|
||||
): string[] => [
|
||||
title,
|
||||
`对比:${ctx.leftName || '(未选择)'}${ctx.leftEncoding ? ` (${ctx.leftEncoding})` : ''} → ${ctx.rightName || '(未选择)'}${ctx.rightEncoding ? ` (${ctx.rightEncoding})` : ''}`,
|
||||
`生成时间:${new Date().toLocaleString('zh-CN')}`,
|
||||
`对比:${ctx.leftName || '(未选择)'}${ctx.leftEncoding ? ` (${ctx.leftEncoding})` : ''}${
|
||||
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}`
|
||||
]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
flex: 0 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user