重新设计diff功能:使用Monaco Editor实现专业级文件对比

新增功能:
- DiffViewer组件:基于Monaco Editor DiffEditor的专业代码对比
- DiffWindow组件:专用的diff对比窗口包装器
- 并排/内联视图切换功能
- 多语言语法高亮支持
- 智能文件类型检测
- 完整的工具栏(下载、刷新、视图切换、行号切换)

技术改进:
- 替代原来的两个独立文件窗口方案
- 使用Monaco Editor提供VS Code同级的对比体验
- 支持大文件错误处理和SSH连接自动重连
- 专业的差异高亮显示(新增/删除/修改)

依赖更新:
- 新增@monaco-editor/react依赖
This commit is contained in:
ZacharyZcR
2025-09-16 23:18:20 +08:00
parent 681a223bed
commit 3f90faf1d0
5 changed files with 444 additions and 44 deletions

View File

@@ -5,6 +5,7 @@ import { useFileSelection } from "./hooks/useFileSelection";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
import { WindowManager, useWindowManager } from "./components/WindowManager";
import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
@@ -1021,64 +1022,37 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
return;
}
// 在新窗口中打开两个文件进行对比
// 使用专用的DiffWindow进行文件对比
console.log('Opening diff comparison:', file1.name, 'vs', file2.name);
// 计算第一个窗口位置
const offsetX1 = 100;
const offsetY1 = 100;
// 计算窗口位置
const offsetX = 100;
const offsetY = 80;
// 计算第二个窗口位置(偏移)
const offsetX2 = 450;
const offsetY2 = 120;
// 创建第一个文件窗口
const windowId1 = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const createWindowComponent1 = (windowId: string) => (
<FileWindow
// 创建diff窗口
const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const createWindowComponent = (windowId: string) => (
<DiffWindow
windowId={windowId}
file={file1}
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX1}
initialY={offsetY1}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
id: windowId1,
type: 'file',
title: `${file1.name} (对比文件1)`,
id: windowId,
type: 'diff',
title: `文件对比: ${file1.name} ${file2.name}`,
isMaximized: false,
component: createWindowComponent1,
component: createWindowComponent,
zIndex: Date.now()
});
// 稍后打开第二个文件窗口
setTimeout(() => {
const windowId2 = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const createWindowComponent2 = (windowId: string) => (
<FileWindow
windowId={windowId}
file={file2}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX2}
initialY={offsetY2}
/>
);
openWindow({
id: windowId2,
type: 'file',
title: `${file2.name} (对比文件2)`,
isMaximized: false,
component: createWindowComponent2,
zIndex: Date.now() + 1
});
}, 200);
toast.success(`正在打开文件对比: ${file1.name}${file2.name}`);
toast.success(`正在对比文件: ${file1.name}${file2.name}`);
}
// 过滤文件并添加新建的临时项目

View File

@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react';
import { DiffEditor } from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText
} from 'lucide-react';
import { readSSHFile, downloadSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios';
import type { FileItem, SSHHost } from '../../../../types/index.js';
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
onDownload1,
onDownload2
}: DiffViewerProps) {
const [content1, setContent1] = useState<string>('');
const [content2, setContent2] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<'side-by-side' | 'inline'>('side-by-side');
const [showLineNumbers, setShowLineNumbers] = useState(true);
// 确保SSH连接有效
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId
});
}
} catch (error) {
console.error('SSH connection check/reconnect failed:', error);
throw error;
}
};
// 加载文件内容
const loadFileContents = async () => {
if (file1.type !== 'file' || file2.type !== 'file') {
setError('只能对比文件类型的项目');
return;
}
try {
setIsLoading(true);
setError(null);
// 确保SSH连接有效
await ensureSSHConnection();
// 并行加载两个文件
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path)
]);
setContent1(response1.content || '');
setContent2(response2.content || '');
} catch (error: any) {
console.error('Failed to load files for diff:', error);
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
setError(`文件过大: ${errorData.error}`);
} else if (error.message?.includes('connection') || error.message?.includes('established')) {
setError(`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`);
} else {
setError(`加载文件失败: ${error.message || errorData?.error || '未知错误'}`);
}
} finally {
setIsLoading(false);
}
};
// 下载文件
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`文件下载成功: ${file.name}`);
}
} catch (error: any) {
console.error('Failed to download file:', error);
toast.error(`下载失败: ${error.message || '未知错误'}`);
}
};
// 获取文件语言类型
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase();
const languageMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'cs': 'csharp',
'php': 'php',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'html': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',
'json': 'json',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'md': 'markdown',
'sql': 'sql',
'sh': 'shell',
'bash': 'shell',
'ps1': 'powershell',
'dockerfile': 'dockerfile'
};
return languageMap[ext || ''] || 'plaintext';
};
// 初始加载
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg">
{/* 工具栏 */}
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-400 mx-2">{file1.name}</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* 视图切换 */}
<Button
variant="outline"
size="sm"
onClick={() => setDiffMode(diffMode === 'side-by-side' ? 'inline' : 'side-by-side')}
>
{diffMode === 'side-by-side' ? '并排' : '内联'}
</Button>
{/* 行号切换 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</Button>
{/* 下载按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={`下载 ${file1.name}`}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={`下载 ${file2.name}`}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
{/* 刷新按钮 */}
<Button
variant="outline"
size="sm"
onClick={loadFileContents}
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* Diff编辑器 */}
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === 'side-by-side',
lineNumbers: showLineNumbers ? 'on' : 'off',
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: 'off',
automaticLayout: true,
readOnly: true,
originalEditable: false,
modifiedEditable: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
},
diffWordWrap: 'off',
ignoreTrimWhitespace: false
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { DiffViewer } from './DiffViewer';
import { useWindowManager } from './WindowManager';
import type { FileItem, SSHHost } from '../../../../types/index.js';
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100
}: DiffWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
const currentWindow = windows.find(w => w.id === windowId);
// 窗口操作处理
const handleClose = () => {
closeWindow(windowId);
};
const handleMinimize = () => {
minimizeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={`文件对比: ${file1.name}${file2.name}`}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMinimize={handleMinimize}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}