feat: DiffLens v0.1.0 初始版本 - 文本对比桌面应用(TypeScript + React + Electron)
This commit is contained in:
+24
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
@@ -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
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+7064
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { DiffLensApi } from './index'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api: DiffLensApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">​</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user