feat: DiffLens v0.1.0 初始版本 - 文本对比桌面应用(TypeScript + React + Electron)

This commit is contained in:
2026-08-17 16:20:56 +08:00
parent fdccb6c1cd
commit b3c50c4b94
23 changed files with 8713 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# 依赖
node_modules/
# 构建产物
out/
dist/
# 系统与编辑器
.DS_Store
Thumbs.db
*.log
.idea/
.vscode/*
!.vscode/extensions.json
!*.map
# Electron
release/
# 环境/密钥
.env
.env.*
*.local
!*.local.example
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

+54
View File
@@ -0,0 +1,54 @@
appId: com.metonateam.difflens
productName: DiffLens
copyright: Copyright © 2026 MetonaTeam
electronLanguages:
- zh-CN
- en-US
directories:
buildResources: build
output: dist
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: difflens
icon: assets/logo.ico
target:
- target: nsis
arch:
- x64
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
icon: assets/logo.png
category: public.app-category.developer-tools
target:
- target: dmg
arch:
- arm64
entitlementsInherit: build/entitlements.mac.plist
linux:
icon: assets/logo.png
target:
- AppImage
- deb
maintainer: thzxx
category: Development
npmRebuild: false
+26
View File
@@ -0,0 +1,26 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
sourcemap: false
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
sourcemap: false
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()]
}
})
+7064
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "difflens",
"version": "0.1.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
"main": "./out/main/index.js",
"private": true,
"scripts": {
"dev": "electron-vite dev",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"diff": "^7.0.0",
"iconv-lite": "^0.6.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/diff": "^7.0.0",
"@types/node": "^22.5.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"electron": "^33.0.0",
"electron-builder": "^25.0.5",
"electron-vite": "^2.3.0",
"typescript": "^5.6.2",
"vite": "^5.4.6"
},
"allowScripts": {
"esbuild@0.21.5": true,
"electron@33.4.11": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

+182
View File
@@ -0,0 +1,182 @@
import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
function createWindow(): void {
// 主窗口
const mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 900,
minHeight: 600,
show: false,
autoHideMenuBar: false,
title: 'DiffLens',
icon: join(__dirname, '../../resources/icon.png'),
backgroundColor: '#0a0e17',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 开发模式加载 dev server,生产加载打包后的 html
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding }。
*/
function decodeText(buf: Buffer): { text: string; encoding: string } {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)' }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE' }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE' }
}
// 严格 UTF-8 探测
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8' }
} catch {
return { text: iconv.decode(buf, 'gbk'), encoding: 'GBK' }
}
}
/** IPC: 打开文件对话框并读取内容 */
ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const result = await dialog.showOpenDialog({
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: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const buf = fs.readFileSync(filePath)
const { text, encoding } = decodeText(buf)
const name = filePath.split(/[\\/]/).pop() ?? filePath
return { path: filePath, name, text, encoding }
})
/** IPC: 打开系统文件管理器定位文件 */
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
shell.showItemInFolder(filePath)
return true
})
/** IPC: 复制文本到剪贴板 */
ipcMain.handle('clipboard:write', (_event, text: string) => {
require('electron').clipboard.writeText(text)
return true
})
function buildMenu(): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ type: 'separator' as const },
{ role: 'quit' as const }
]
}
]
: []),
{
label: '文件',
submenu: [
{ label: '打开文件(左侧)', accelerator: 'CmdOrCtrl+O', click: () => sendMenuCommand('open-left') },
{ label: '打开文件(右侧)', accelerator: 'CmdOrCtrl+Alt+O', click: () => sendMenuCommand('open-right') },
{ type: 'separator' },
isMac ? { role: 'close' as const } : { role: 'quit' as const }
]
},
{
label: '编辑',
submenu: [
{ role: 'undo' as const },
{ role: 'redo' as const },
{ type: 'separator' as const },
{ role: 'cut' as const },
{ role: 'copy' as const },
{ role: 'paste' as const },
{ role: 'selectAll' as const }
]
},
{
label: '视图',
submenu: [
{ role: 'reload' as const },
{ role: 'forceReload' as const },
{ role: 'toggleDevTools' as const },
{ type: 'separator' as const },
{ role: 'resetZoom' as const },
{ role: 'zoomIn' as const },
{ role: 'zoomOut' as const },
{ type: 'separator' as const },
{ role: 'togglefullscreen' as const }
]
},
{
label: '帮助',
submenu: [{ role: 'about' as const }]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
/** 向渲染进程广播菜单指令 */
function sendMenuCommand(cmd: string): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('menu:command', cmd)
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
buildMenu()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+9
View File
@@ -0,0 +1,9 @@
import { DiffLensApi } from './index'
declare global {
interface Window {
api: DiffLensApi
}
}
export {}
+40
View File
@@ -0,0 +1,40 @@
import { contextBridge, ipcRenderer } from 'electron'
/** 打开文件返回的数据结构 */
export interface FileData {
path: string
name: string
text: string
encoding: string
}
/** 主进程暴露给渲染进程的安全 API */
const api = {
openFile: (side?: 'left' | 'right'): Promise<FileData | null> =>
ipcRenderer.invoke('file:open', side ?? null),
showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> =>
ipcRenderer.invoke('clipboard:write', text),
/** 订阅顶部菜单命令,返回取消订阅函数 */
onMenuCommand: (callback: (cmd: string) => void): (() => void) => {
const listener = (_e: unknown, cmd: string): void => callback(cmd)
ipcRenderer.on('menu:command', listener)
return () => {
ipcRenderer.removeListener('menu:command', listener)
}
}
}
export type DiffLensApi = typeof api
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// 非隔离环境下透传到 window
;(window as unknown as { api: DiffLensApi }).api = api
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>DiffLens</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useMemo, useState, useCallback, type ReactElement } from 'react'
import { computeDiff } from './diff/diffEngine'
import type { DiffResult, DiffSummary } from './diff/diffEngine'
import DiffView from './components/DiffView'
import Toolbar from './components/Toolbar'
import type { PaneMeta } from './components/DiffView'
interface PaneState {
meta: PaneMeta
path: string
text: string
}
interface DiffOpts {
trimWhitespace: boolean
ignoreCase: boolean
}
function LogoIcon(): ReactElement {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth="2.1" />
<line x1="15.5" y1="15.5" x2="20.5" y2="20.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
<path d="M5 11h3.4M5 14h2" stroke="#030712" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
function EmptyPane({
side,
onOpen,
onDrop
}: {
side: 'left' | 'right'
onOpen: (s: 'left' | 'right') => void
onDrop: (s: 'left' | 'right', file: File) => void
}): ReactElement {
const [drag, setDrag] = useState(false)
return (
<div
className={'pane-empty' + (drag ? ' dragging' : '')}
onDragOver={(e) => {
e.preventDefault()
setDrag(true)
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault()
setDrag(false)
const f = e.dataTransfer.files[0]
if (f) onDrop(side, f)
}}
>
<div>
<span className={'pane-dot ' + side} style={{ display: 'inline-block' }} />
<h3>{side === 'left' ? '左侧' : '右侧'}</h3>
<p>{side === 'left' ? '被对比的原始文本' : '用于对比的新文本'}</p>
<button className="btn primary" onClick={() => onOpen(side)}>
</button>
<div className="pane-drop-hint"></div>
</div>
</div>
)
}
export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null)
const [options, setOptions] = useState<DiffOpts>({ trimWhitespace: false, ignoreCase: false })
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0)
const openPane = useCallback(async (side: 'left' | 'right') => {
const data = await window.api.openFile(side)
if (!data) return
const pane: PaneState = { meta: { name: data.name, encoding: data.encoding }, path: data.path, text: data.text }
if (side === 'left') setPaneL(pane)
else setPaneR(pane)
}, [])
const dropPane = useCallback(async (side: 'left' | 'right', file: File) => {
const text = await file.text().catch(() => '')
const pane: PaneState = { meta: { name: file.name, encoding: 'UTF-8' }, path: '', text }
if (side === 'left') setPaneL(pane)
else setPaneR(pane)
}, [])
// 订阅顶层菜单命令
useEffect(() => {
return window.api.onMenuCommand((cmd) => {
if (cmd === 'open-left') void openPane('left')
else if (cmd === 'open-right') void openPane('right')
})
}, [openPane])
const diff: DiffResult = useMemo(() => {
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options)
}, [paneL, paneR, options])
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
const summary: DiffSummary = diff.summary
// diff 内容变化时重置导航
useEffect(() => {
setNavIndex(0)
setActiveRowId(null)
}, [paneL, paneR, options])
const go = useCallback(
(dir: 1 | -1) => {
const len = changedRows.length
if (len === 0) return
let ni = navIndex + dir
if (ni >= len) ni = 0
if (ni < 0) ni = len - 1
setNavIndex(ni)
setActiveRowId(changedRows[ni].id)
},
[changedRows, navIndex]
)
const anyPane = paneL !== null || paneR !== null
const leftMeta = paneL?.meta ?? null
const rightMeta = paneR?.meta ?? null
return (
<div className="app">
<header className="app-header">
<div className="logo">
<div className="logo-mark">
<LogoIcon />
</div>
<div>
<span className="logo-title">DiffLens</span>
<span className="logo-sub">TEXT DIFF</span>
</div>
</div>
<div className="header-spacer" />
<button className="btn ghost" onClick={() => void openPane('left')}>
</button>
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
<button
className="btn ghost"
onClick={() => {
setPaneL(null)
setPaneR(null)
}}
>
</button>
</header>
{anyPane ? (
<>
<DiffView
diff={diff}
leftMeta={leftMeta}
rightMeta={rightMeta}
activeRowId={activeRowId}
onOpen={(s) => void openPane(s)}
/>
<Toolbar
options={options}
onOptionsChange={setOptions}
summary={summary}
navIndex={navIndex}
navCount={changedRows.length}
onNav={go}
/>
</>
) : (
<div className="diff-main">
<EmptyPane side="left" onOpen={(s) => void openPane(s)} onDrop={(s, f) => void dropPane(s, f)} />
<EmptyPane side="right" onOpen={(s) => void openPane(s)} onDrop={(s, f) => void dropPane(s, f)} />
</div>
)}
<footer className="status-bar">
<div className="status-left">
<span className="status-item">
<b>{paneL ? paneL.meta.name : '—'}</b>
</span>
<span className="status-item">
<b>{paneR ? paneR.meta.name : '—'}</b>
</span>
<span className="status-item">
<b>{summary.changedLines}</b>
</span>
</div>
<div className="status-spacer" />
<div className="status-right">
{summary.changedLines === 0 ? (
<span className="eq-badge same"></span>
) : (
<span className="eq-badge diff"></span>
)}
</div>
</footer>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, type ReactNode, type ReactElement, type RefObject } from 'react'
import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine'
export interface PaneMeta {
name: string
encoding: string
}
interface DiffViewProps {
diff: DiffResult
leftMeta: PaneMeta | null
rightMeta: PaneMeta | null
activeRowId: string | null
onOpen: (side: 'left' | 'right') => void
}
function renderCellText(cell: SideCell): ReactNode {
if (cell.segs) {
return cell.segs.map((s, i) => (
<span key={i} className={'seg seg-' + s.kind}>
{s.text}
</span>
))
}
if (cell.text === null) {
return <span className="plain-empty">&#8203;</span>
}
return <span className="plain">{cell.text}</span>
}
function SidePanel({
rows,
side,
meta,
onOpen,
scrollRef,
onScroll,
activeRowId
}: {
rows: DiffRow[]
side: 'left' | 'right'
meta: PaneMeta | null
onOpen: (side: 'left' | 'right') => void
scrollRef: RefObject<HTMLDivElement>
onScroll: () => void
activeRowId: string | null
}): ReactElement {
return (
<div className={'pane pane-mid pane-' + side}>
<div className="panel-head">
<span className={'pane-dot ' + side} />
<span className="pane-file">{meta ? meta.name : '(未选择)'}</span>
{meta && <span className="pane-meta">{meta.encoding}</span>}
<span className="pane-actions">
<button className="btn" onClick={() => onOpen(side)}>
{meta ? '重新选择' : '打开文件'}
</button>
</span>
</div>
<div className="diff-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="diff-columns">
{rows.map((row) => {
const cell = side === 'left' ? row.left : row.right
const cls =
'diff-row ' +
row.rowKind +
(activeRowId === row.id ? ' active' : '') +
(side === 'left' && row.rowKind === 'added' ? ' base' : '')
return (
<div className={cls} data-row={row.id} key={row.id + '-' + side}>
<span className={'ln ' + side}>{cell.lineNo ?? ''}</span>
<span className="tx">{renderCellText(cell)}</span>
</div>
)
})}
</div>
</div>
</div>
)
}
export default function DiffView({
diff,
leftMeta,
rightMeta,
activeRowId,
onOpen
}: DiffViewProps): ReactElement {
const leftRef = useRef<HTMLDivElement>(null)
const rightRef = useRef<HTMLDivElement>(null)
const syncing = useRef(false)
const makeScrollSync =
(target: 'left' | 'right') =>
(): void => {
if (syncing.current) return
syncing.current = true
const src = target === 'left' ? leftRef.current : rightRef.current
const dst = target === 'left' ? rightRef.current : leftRef.current
if (src && dst) dst.scrollTop = src.scrollTop
requestAnimationFrame(() => {
syncing.current = false
})
}
// 差异导航定位:命中行后滚动到中间,并靠滚动联动自动同步右侧
useEffect(() => {
if (!activeRowId) return
const el = leftRef.current?.querySelector<HTMLElement>(`[data-row="${activeRowId}"]`)
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [activeRowId])
return (
<div className="diff-view">
<div className="diff-main">
<SidePanel
rows={diff.rows}
side="left"
meta={leftMeta}
onOpen={onOpen}
scrollRef={leftRef}
onScroll={makeScrollSync('left')}
activeRowId={activeRowId}
/>
<SidePanel
rows={diff.rows}
side="right"
meta={rightMeta}
onOpen={onOpen}
scrollRef={rightRef}
onScroll={makeScrollSync('right')}
activeRowId={activeRowId}
/>
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { type ReactElement } from 'react'
import { DiffSummary } from '../diff/diffEngine'
export interface DiffOptions {
trimWhitespace: boolean
ignoreCase: boolean
}
interface ToolbarProps {
options: DiffOptions
onOptionsChange: (options: DiffOptions) => void
summary: DiffSummary
navIndex: number
navCount: number
onNav: (dir: 1 | -1) => void
}
export default function Toolbar({
options,
onOptionsChange,
summary,
navIndex,
navCount,
onNav
}: ToolbarProps): ReactElement {
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const canNav = navCount > 0
return (
<div className="toolbar">
<div className="tool-group">
<span className="tool-label"></span>
<label className="switch">
<input
type="checkbox"
checked={options.trimWhitespace}
onChange={(e) => set({ trimWhitespace: e.target.checked })}
/>
</label>
<label className="switch">
<input
type="checkbox"
checked={options.ignoreCase}
onChange={(e) => set({ ignoreCase: e.target.checked })}
/>
</label>
</div>
<div className="tool-group">
<button
className="nav-btn"
title="上一处差异"
disabled={!canNav}
onClick={() => onNav(-1)}
>
</button>
<span className="nav-count">
{navCount > 0 ? `${navIndex + 1} / ${navCount}` : '0 / 0'}
</span>
<button
className="nav-btn"
title="下一处差异"
disabled={!canNav}
onClick={() => onNav(1)}
>
</button>
</div>
<div className="header-spacer" style={{ margin: 0 }} />
<div className="stats">
<span className="badge add">
<span className="dot" />+{summary.inserted}
</span>
<span className="badge del">
<span className="dot" />{summary.deleted}
</span>
<span className="badge mod">
<span className="dot" />~{summary.modified}
</span>
</div>
</div>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { diffArrays, diffWordsWithSpace } from 'diff'
/** 词级分段类型 */
export type SegKind = 'common' | 'insert' | 'delete'
export interface Seg {
text: string
kind: SegKind
}
export interface SideCell {
/** 原文行号(1 基),缺行时为 null(该侧为空槽) */
lineNo: number | null
/** 该行原始文本,无行时为 null */
text: string | null
/** 词级高亮分段,仅修改行填充 */
segs: Seg[] | null
}
export type RowKind = 'unchanged' | 'removed' | 'added' | 'modified'
export interface DiffRow {
id: string
rowKind: RowKind
isChanged: boolean
left: SideCell
right: SideCell
}
export interface DiffSummary {
changedLines: number
inserted: number
deleted: number
modified: number
}
export interface DiffOptions {
/** 忽略行首/行尾空白差异 */
trimWhitespace?: boolean
/** 忽略大小写 */
ignoreCase?: boolean
}
export interface DiffResult {
rows: DiffRow[]
summary: DiffSummary
}
let rowSeq = 0
function emptyLine(): SideCell {
return { lineNo: null, text: null, segs: null }
}
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
const parts = diffWordsWithSpace(leftText, rightText)
const left: Seg[] = []
const right: Seg[] = []
for (const part of parts) {
if (part.added) {
right.push({ text: part.value, kind: 'insert' })
} else if (part.removed) {
left.push({ text: part.value, kind: 'delete' })
} else {
left.push({ text: part.value, kind: 'common' })
right.push({ text: part.value, kind: 'common' })
}
}
return { left, right }
}
/** 按行切分;空字符串返回空数组 */
function splitLines(text: string): string[] {
if (text === '') return []
return text.split(/\r?\n/)
}
/**
* 计算文本差异。
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
* 删除段与新增段配对为“修改”行并做词级内联高亮。
* 展示文本始终取原始行,选项只影响“是否判等”。
*/
export function computeDiff(
leftText: string,
rightText: string,
options: DiffOptions = {}
): DiffResult {
rowSeq = 0
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)
const norm = (s: string): string => {
let v = options.trimWhitespace ? s.trim() : s
if (options.ignoreCase) v = v.toLowerCase()
return v
}
const leftNorm = leftOrig.map(norm)
const rightNorm = rightOrig.map(norm)
const parts = diffArrays(leftNorm, rightNorm)
const rows: DiffRow[] = []
let leftPtr = 1 // 左侧下一个待消费行号(1 基)
let rightPtr = 1
// 暂存的纯删除行(等待与随后新增配对)
let queuedRemoved: { text: string; lineNo: number }[] = []
const flushRemoved = (): void => {
for (const r of queuedRemoved) {
rows.push({
id: `r${rowSeq++}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: r.lineNo, text: r.text, segs: null },
right: emptyLine()
})
}
queuedRemoved = []
}
/** 删除段与新增段配对为修改行 */
const commitModified = (addedRows: { text: string; lineNo: number }[]): void => {
const count = Math.max(queuedRemoved.length, addedRows.length)
for (let i = 0; i < count; i++) {
const l = queuedRemoved[i]
const r = addedRows[i]
if (l && r) {
const { left: lSegs, right: rSegs } = wordSegments(l.text, r.text)
rows.push({
id: `r${rowSeq++}`,
rowKind: 'modified',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
right: { lineNo: r.lineNo, text: r.text, segs: rSegs }
})
} else if (l) {
rows.push({
id: `r${rowSeq++}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: null },
right: emptyLine()
})
} else if (r) {
rows.push({
id: `r${rowSeq++}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
right: { lineNo: r.lineNo, text: r.text, segs: null }
})
}
}
queuedRemoved = []
}
for (const part of parts) {
const value = part.value as string[] // diffArrays 的 value 为行数组
if (part.removed) {
const len = value.length
for (let i = 0; i < len; i++) {
queuedRemoved.push({ text: leftOrig[leftPtr - 1 + i], lineNo: leftPtr + i })
}
leftPtr += len
} else if (part.added) {
const len = value.length
const addedRows: { text: string; lineNo: number }[] = []
for (let i = 0; i < len; i++) {
addedRows.push({ text: rightOrig[rightPtr - 1 + i], lineNo: rightPtr + i })
}
rightPtr += len
if (queuedRemoved.length > 0) {
commitModified(addedRows)
} else {
for (const r of addedRows) {
rows.push({
id: `r${rowSeq++}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
right: { lineNo: r.lineNo, text: r.text, segs: null }
})
}
}
} else {
// common
if (queuedRemoved.length > 0) flushRemoved()
const len = value.length
for (let i = 0; i < len; i++) {
const lt = leftOrig[leftPtr - 1 + i]
const rt = rightOrig[rightPtr - 1 + i]
rows.push({
id: `r${rowSeq++}`,
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: leftPtr + i, text: lt, segs: null },
right: { lineNo: rightPtr + i, text: rt, segs: null }
})
}
leftPtr += len
rightPtr += len
}
}
if (queuedRemoved.length > 0) flushRemoved()
const summary: DiffSummary = {
changedLines: rows.filter((r) => r.isChanged).length,
inserted: rows.filter((r) => r.rowKind === 'added').length,
deleted: rows.filter((r) => r.rowKind === 'removed').length,
modified: rows.filter((r) => r.rowKind === 'modified').length
}
return { rows, summary }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+558
View File
@@ -0,0 +1,558 @@
/* ============ DiffLens 全局主题(暗色科幻) ============ */
:root {
--bg: #0a0e17;
--bg-deep: #070b12;
--panel: #0e1420;
--panel-2: #101828;
--surface: rgba(148, 163, 255, 0.06);
--surface-hover: rgba(124, 108, 255, 0.14);
--border: rgba(148, 163, 255, 0.14);
--border-strong: rgba(148, 163, 255, 0.32);
--text: #dbe3f4;
--muted: #74809a;
--accent: #8b7cff;
--accent-2: #22d3ee;
--pink: #f472b6;
/* diff 语义色 */
--add-bg: rgba(34, 197, 94, 0.13);
--add-fg: #7ee2a8;
--add-inline: rgba(34, 197, 94, 0.30);
--del-bg: rgba(248, 113, 113, 0.12);
--del-fg: #f7a9a9;
--del-inline: rgba(248, 113, 113, 0.30);
--mod-bg: rgba(245, 158, 11, 0.11);
--mod-fg: #f6bd87;
--mod-inline-delete: rgba(248, 113, 113, 0.34);
--mod-inline-insert: rgba(34, 197, 94, 0.34);
--line-height: 21px;
--mono: 'Cascadia Mono', 'JetBrains Mono', Consolas, 'Menlo', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: 'Segoe UI', 'Microsoft YaHei', system-ui, sans-serif;
font-size: 13px;
color: var(--text);
background: radial-gradient(1200px 600px at 20% -10%, rgba(124, 108, 255, 0.16), transparent 60%),
radial-gradient(1000px 500px at 90% 110%, rgba(34, 211, 238, 0.12), transparent 55%),
var(--bg);
overflow: hidden;
user-select: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 255, 0.18);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 255, 0.34);
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============ 整体布局 ============ */
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.app-header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 14px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(124, 108, 255, 0.10), rgba(10, 14, 23, 0));
position: relative;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-mark {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 18px rgba(124, 108, 255, 0.55);
flex: none;
}
.logo-title {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.5px;
background: linear-gradient(90deg, #c9bfff, var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.logo-sub {
display: block;
font-size: 10px;
font-weight: 500;
color: var(--muted);
letter-spacing: 3px;
}
.header-spacer {
flex: 1;
}
/* ============ 面板头(左右栏位) ============ */
.panel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: var(--panel);
border-bottom: 1px solid var(--border);
min-height: 46px;
}
.pane-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.pane-dot.left {
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.pane-dot.right {
background: var(--accent-2);
box-shadow: 0 0 8px var(--accent-2);
}
.pane-file {
font-weight: 600;
font-family: var(--mono);
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pane-meta {
color: var(--muted);
font-size: 11px;
}
.pane-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
transition: all 0.16s ease;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.btn.primary {
background: linear-gradient(135deg, var(--accent), #6a4dff);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 16px rgba(124, 108, 255, 0.35);
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: transparent;
}
.btn.ghost:hover:not(:disabled) {
background: var(--surface);
}
/* ============ 空面板 CTA ============ */
.pane-empty {
position: relative;
flex: 1;
display: grid;
place-items: center;
border: 1px dashed var(--border-strong);
border-radius: 12px;
margin: 14px;
background: var(--surface);
transition: all 0.18s ease;
text-align: center;
padding: 30px;
}
.pane-empty.dragging {
border-color: var(--accent);
background: rgba(124, 108, 255, 0.08);
box-shadow: inset 0 0 30px rgba(124, 108, 255, 0.15);
}
.pane-empty h3 {
font-size: 15px;
margin-bottom: 4px;
}
.pane-empty p {
color: var(--muted);
margin-bottom: 16px;
font-size: 12px;
}
.pane-drop-hint {
color: var(--muted);
font-size: 11px;
margin-top: 12px;
}
/* ============ Diff 视图 ============ */
.diff-main {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
position: relative;
}
.diff-view {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.diff-empty-pane {
flex: 1;
display: grid;
place-items: center;
color: var(--muted);
border-left: 1px solid var(--border);
}
.pane-mid {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
background: linear-gradient(180deg, rgba(10, 14, 23, 0.6), var(--bg-deep));
}
.diff-scroll {
flex: 1;
overflow: auto;
position: relative;
}
.diff-columns {
min-width: max-content;
padding-bottom: 12px;
}
.diff-row {
display: flex;
height: var(--line-height);
line-height: var(--line-height);
font-family: var(--mono);
font-size: 12.5px;
white-space: pre;
width: 100%;
}
.diff-row.base {
background: rgba(0, 0, 0, 0.12);
}
.ln {
flex: 0 0 46px;
text-align: right;
padding-right: 8px;
color: #4d5a74;
background: rgba(124, 108, 255, 0.05);
border-right: 1px solid var(--border);
position: sticky;
left: 0;
user-select: none;
}
.diff-row.unchanged .ln {
color: #46536e;
}
.tx {
flex: 1;
padding: 0 10px;
overflow: hidden;
}
/* 行级别背景 */
.diff-row.modified {
background: var(--mod-bg);
}
.diff-row.modified .ln {
color: var(--mod-fg);
background: rgba(245, 158, 11, 0.16);
border-right-color: rgba(245, 158, 11, 0.3);
}
.diff-row.added {
background: var(--add-bg);
}
.diff-row.added .ln {
color: var(--add-fg);
background: rgba(34, 197, 94, 0.14);
border-right-color: rgba(34, 197, 94, 0.3);
}
.diff-row.removed {
background: var(--del-bg);
}
.diff-row.removed .ln {
color: var(--del-fg);
background: rgba(248, 113, 113, 0.13);
border-right-color: rgba(248, 113, 113, 0.3);
}
/* 焦点行(差异导航定位) */
.diff-row.active {
outline: 1px solid var(--accent-2);
outline-offset: -1px;
background: rgba(34, 211, 238, 0.10);
}
/* 词级内联高亮 */
.seg {
border-radius: 2px;
}
.seg-insert {
background: var(--mod-inline-insert);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.25);
}
.seg-delete {
background: var(--mod-inline-delete);
text-decoration: line-through;
text-decoration-color: rgba(248, 113, 113, 0.8);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.2);
}
/* 空内容行占位显示 */
.diff-row .tx .plain-empty {
color: transparent;
}
/* ============ 工具栏 ============ */
.toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
padding: 8px 18px;
border-top: 1px solid var(--border);
background: rgba(10, 14, 23, 0.7);
flex-wrap: wrap;
}
.tool-group {
display: flex;
align-items: center;
gap: 8px;
}
.tool-label {
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
/* 选项开关(checkbox */
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: var(--text);
user-select: none;
}
.switch input {
appearance: none;
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border-strong);
background: var(--surface);
cursor: pointer;
position: relative;
transition: all 0.15s ease;
}
.switch input:checked {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
}
.switch input:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* 导航按钮 */
.nav-btn {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface);
border: 1px solid var(--border);
font-size: 15px;
color: var(--text);
transition: all 0.15s ease;
}
.nav-btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
box-shadow: 0 0 12px rgba(124, 108, 255, 0.3);
}
.nav-count {
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: center;
}
/* 统计徽章 */
.stats {
display: flex;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.badge.add {
color: var(--add-fg);
background: var(--add-bg);
}
.badge.add .dot {
background: var(--add-fg);
box-shadow: 0 0 8px var(--add-fg);
}
.badge.del {
color: var(--del-fg);
background: var(--del-bg);
}
.badge.del .dot {
background: var(--del-fg);
box-shadow: 0 0 8px var(--del-fg);
}
.badge.mod {
color: var(--mod-fg);
background: var(--mod-bg);
}
.badge.mod .dot {
background: var(--mod-fg);
box-shadow: 0 0 8px var(--mod-fg);
}
/* ============ 状态栏 ============ */
.status-bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
padding: 4px 18px;
font-size: 11px;
color: var(--muted);
border-top: 1px solid rgba(148, 163, 255, 0.08);
}
.status-left {
display: flex;
gap: 14px;
align-items: center;
}
.status-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-item b {
color: var(--text);
font-weight: 600;
}
.status-spacer {
flex: 1;
}
.status-right {
display: inline-flex;
align-items: center;
gap: 6px;
}
.eq-badge {
padding: 1px 8px;
border-radius: 999px;
font-weight: 700;
}
.eq-badge.same {
color: var(--add-fg);
background: var(--add-bg);
}
.eq-badge.diff {
color: var(--muted);
background: var(--surface);
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*"
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/src/*"]
}
}
}