Cleanup files and improve file manager.
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
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';
|
||||
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;
|
||||
@@ -28,13 +33,15 @@ export function DiffViewer({
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
onDownload1,
|
||||
onDownload2
|
||||
onDownload2,
|
||||
}: DiffViewerProps) {
|
||||
const [content1, setContent1] = useState<string>('');
|
||||
const [content2, setContent2] = useState<string>('');
|
||||
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 [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
|
||||
"side-by-side",
|
||||
);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
|
||||
// 确保SSH连接有效
|
||||
@@ -52,19 +59,19 @@ export function DiffViewer({
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SSH connection check/reconnect failed:', error);
|
||||
console.error("SSH connection check/reconnect failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文件内容
|
||||
const loadFileContents = async () => {
|
||||
if (file1.type !== 'file' || file2.type !== 'file') {
|
||||
setError('只能对比文件类型的项目');
|
||||
if (file1.type !== "file" || file2.type !== "file") {
|
||||
setError("只能对比文件类型的项目");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,21 +85,28 @@ export function DiffViewer({
|
||||
// 并行加载两个文件
|
||||
const [response1, response2] = await Promise.all([
|
||||
readSSHFile(sshSessionId, file1.path),
|
||||
readSSHFile(sshSessionId, file2.path)
|
||||
readSSHFile(sshSessionId, file2.path),
|
||||
]);
|
||||
|
||||
setContent1(response1.content || '');
|
||||
setContent2(response2.content || '');
|
||||
setContent1(response1.content || "");
|
||||
setContent2(response2.content || "");
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load files for diff:', error);
|
||||
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 if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
setError(
|
||||
`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`,
|
||||
);
|
||||
} else {
|
||||
setError(`加载文件失败: ${error.message || errorData?.error || '未知错误'}`);
|
||||
setError(
|
||||
`加载文件失败: ${error.message || errorData?.error || "未知错误"}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -112,10 +126,12 @@ export function DiffViewer({
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
@@ -126,44 +142,44 @@ export function DiffViewer({
|
||||
toast.success(`文件下载成功: ${file.name}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to download file:', error);
|
||||
toast.error(`下载失败: ${error.message || '未知错误'}`);
|
||||
console.error("Failed to download file:", error);
|
||||
toast.error(`下载失败: ${error.message || "未知错误"}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件语言类型
|
||||
const getFileLanguage = (fileName: string): string => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
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'
|
||||
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';
|
||||
return languageMap[ext || ""] || "plaintext";
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
@@ -205,7 +221,9 @@ export function DiffViewer({
|
||||
<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>
|
||||
<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>
|
||||
@@ -216,9 +234,13 @@ export function DiffViewer({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDiffMode(diffMode === 'side-by-side' ? 'inline' : 'side-by-side')}
|
||||
onClick={() =>
|
||||
setDiffMode(
|
||||
diffMode === "side-by-side" ? "inline" : "side-by-side",
|
||||
)
|
||||
}
|
||||
>
|
||||
{diffMode === 'side-by-side' ? '并排' : '内联'}
|
||||
{diffMode === "side-by-side" ? "并排" : "内联"}
|
||||
</Button>
|
||||
|
||||
{/* 行号切换 */}
|
||||
@@ -227,7 +249,11 @@ export function DiffViewer({
|
||||
size="sm"
|
||||
onClick={() => setShowLineNumbers(!showLineNumbers)}
|
||||
>
|
||||
{showLineNumbers ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
{showLineNumbers ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
@@ -252,11 +278,7 @@ export function DiffViewer({
|
||||
</Button>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadFileContents}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -271,22 +293,22 @@ export function DiffViewer({
|
||||
language={getFileLanguage(file1.name)}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
renderSideBySide: diffMode === 'side-by-side',
|
||||
lineNumbers: showLineNumbers ? 'on' : 'off',
|
||||
renderSideBySide: diffMode === "side-by-side",
|
||||
lineNumbers: showLineNumbers ? "on" : "off",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
wordWrap: 'off',
|
||||
wordWrap: "off",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
modifiedEditable: false,
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible'
|
||||
vertical: "visible",
|
||||
horizontal: "visible",
|
||||
},
|
||||
diffWordWrap: 'off',
|
||||
ignoreTrimWhitespace: false
|
||||
diffWordWrap: "off",
|
||||
ignoreTrimWhitespace: false,
|
||||
}}
|
||||
loading={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@@ -300,4 +322,4 @@ export function DiffViewer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
import { useWindowManager } from './WindowManager';
|
||||
import type { FileItem, SSHHost } from '../../../../types/index.js';
|
||||
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;
|
||||
@@ -21,11 +21,12 @@ export function DiffWindow({
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 150,
|
||||
initialY = 100
|
||||
initialY = 100,
|
||||
}: DiffWindowProps) {
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const currentWindow = windows.find(w => w.id === windowId);
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// 窗口操作处理
|
||||
const handleClose = () => {
|
||||
@@ -72,4 +73,4 @@ export function DiffWindow({
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Minus, Square, X, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
interface DraggableWindowProps {
|
||||
title: string;
|
||||
@@ -33,14 +33,17 @@ export function DraggableWindow({
|
||||
onMaximize,
|
||||
isMaximized = false,
|
||||
zIndex = 1000,
|
||||
onFocus
|
||||
onFocus,
|
||||
}: DraggableWindowProps) {
|
||||
// 窗口状态
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
const [size, setSize] = useState({ width: initialWidth, height: initialHeight });
|
||||
const [size, setSize] = useState({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<string>('');
|
||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||
|
||||
// 拖拽开始位置
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
@@ -55,88 +58,120 @@ export function DraggableWindow({
|
||||
}, [onFocus]);
|
||||
|
||||
// 拖拽处理
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (isMaximized) return;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isMaximized) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
onFocus?.();
|
||||
}, [isMaximized, position, onFocus]);
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, onFocus],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDragging && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
setPosition({
|
||||
x: Math.max(0, Math.min(window.innerWidth - size.width, windowStart.x + deltaX)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 40, windowStart.y + deltaY)) // 保持标题栏可见
|
||||
});
|
||||
}
|
||||
|
||||
if (isResizing && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
let newX = position.x;
|
||||
let newY = position.y;
|
||||
|
||||
if (resizeDirection.includes('right')) {
|
||||
newWidth = Math.max(minWidth, windowStart.x + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes('left')) {
|
||||
newWidth = Math.max(minWidth, size.width - deltaX);
|
||||
newX = Math.min(windowStart.x + deltaX, position.x + size.width - minWidth);
|
||||
}
|
||||
if (resizeDirection.includes('bottom')) {
|
||||
newHeight = Math.max(minHeight, windowStart.y + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes('top')) {
|
||||
newHeight = Math.max(minHeight, size.height - deltaY);
|
||||
newY = Math.min(windowStart.y + deltaY, position.y + size.height - minHeight);
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(window.innerWidth - size.width, windowStart.x + deltaX),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(window.innerHeight - 40, windowStart.y + deltaY),
|
||||
), // 保持标题栏可见
|
||||
});
|
||||
}
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
}, [isDragging, isResizing, isMaximized, dragStart, windowStart, size, position, minWidth, minHeight, resizeDirection]);
|
||||
if (isResizing && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
let newX = position.x;
|
||||
let newY = position.y;
|
||||
|
||||
if (resizeDirection.includes("right")) {
|
||||
newWidth = Math.max(minWidth, windowStart.x + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes("left")) {
|
||||
newWidth = Math.max(minWidth, size.width - deltaX);
|
||||
newX = Math.min(
|
||||
windowStart.x + deltaX,
|
||||
position.x + size.width - minWidth,
|
||||
);
|
||||
}
|
||||
if (resizeDirection.includes("bottom")) {
|
||||
newHeight = Math.max(minHeight, windowStart.y + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes("top")) {
|
||||
newHeight = Math.max(minHeight, size.height - deltaY);
|
||||
newY = Math.min(
|
||||
windowStart.y + deltaY,
|
||||
position.y + size.height - minHeight,
|
||||
);
|
||||
}
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
},
|
||||
[
|
||||
isDragging,
|
||||
isResizing,
|
||||
isMaximized,
|
||||
dragStart,
|
||||
windowStart,
|
||||
size,
|
||||
position,
|
||||
minWidth,
|
||||
minHeight,
|
||||
resizeDirection,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
setResizeDirection('');
|
||||
setResizeDirection("");
|
||||
}, []);
|
||||
|
||||
// 调整大小处理
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => {
|
||||
if (isMaximized) return;
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, direction: string) => {
|
||||
if (isMaximized) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: size.width, y: size.height });
|
||||
onFocus?.();
|
||||
}, [isMaximized, size, onFocus]);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: size.width, y: size.height });
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, size, onFocus],
|
||||
);
|
||||
|
||||
// 全局事件监听
|
||||
useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = isDragging ? 'grabbing' : 'resizing';
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
@@ -152,14 +187,14 @@ export function DraggableWindow({
|
||||
className={cn(
|
||||
"absolute bg-card border border-border rounded-lg shadow-2xl",
|
||||
"select-none overflow-hidden",
|
||||
isMaximized ? "inset-0" : ""
|
||||
isMaximized ? "inset-0" : "",
|
||||
)}
|
||||
style={{
|
||||
left: isMaximized ? 0 : position.x,
|
||||
top: isMaximized ? 0 : position.y,
|
||||
width: isMaximized ? '100%' : size.width,
|
||||
height: isMaximized ? '100%' : size.height,
|
||||
zIndex
|
||||
width: isMaximized ? "100%" : size.width,
|
||||
height: isMaximized ? "100%" : size.height,
|
||||
zIndex,
|
||||
}}
|
||||
onClick={handleWindowClick}
|
||||
>
|
||||
@@ -169,7 +204,7 @@ export function DraggableWindow({
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2",
|
||||
"bg-muted/50 text-foreground border-b border-border",
|
||||
"cursor-grab active:cursor-grabbing"
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleTitleDoubleClick}
|
||||
@@ -223,7 +258,10 @@ export function DraggableWindow({
|
||||
</div>
|
||||
|
||||
{/* 窗口内容 */}
|
||||
<div className="flex-1 overflow-auto" style={{ height: 'calc(100% - 40px)' }}>
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ height: "calc(100% - 40px)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -233,40 +271,40 @@ export function DraggableWindow({
|
||||
{/* 边缘调整 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'top')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'bottom')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'left')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'right')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "right")}
|
||||
/>
|
||||
|
||||
{/* 角落调整 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'top-left')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'top-right')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-right")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'bottom-left')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, 'bottom-right')}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Replace
|
||||
} from 'lucide-react';
|
||||
Replace,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SiJavascript,
|
||||
SiTypescript,
|
||||
@@ -43,13 +43,13 @@ import {
|
||||
SiMarkdown,
|
||||
SiGnubash,
|
||||
SiMysql,
|
||||
SiDocker
|
||||
} from 'react-icons/si';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { oneDark } from '@uiw/codemirror-themes';
|
||||
import { languages, loadLanguage } from '@uiw/codemirror-extensions-langs';
|
||||
SiDocker,
|
||||
} from "react-icons/si";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@uiw/codemirror-themes";
|
||||
import { languages, loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -75,123 +75,183 @@ interface FileViewerProps {
|
||||
|
||||
// 获取编程语言的官方图标
|
||||
function getLanguageIcon(filename: string): React.ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
// 特殊文件名处理
|
||||
if (['dockerfile'].includes(baseName)) {
|
||||
if (["dockerfile"].includes(baseName)) {
|
||||
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||||
}
|
||||
if (['makefile', 'rakefile', 'gemfile'].includes(baseName)) {
|
||||
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return <SiRuby className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'js': <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
'jsx': <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
'ts': <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
'tsx': <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
'py': <SiPython className="w-6 h-6 text-blue-400" />,
|
||||
'java': <SiOracle className="w-6 h-6 text-red-500" />,
|
||||
'cpp': <SiCplusplus className="w-6 h-6 text-blue-600" />,
|
||||
'c': <SiC className="w-6 h-6 text-blue-700" />,
|
||||
'cs': <SiDotnet className="w-6 h-6 text-purple-600" />,
|
||||
'php': <SiPhp className="w-6 h-6 text-indigo-500" />,
|
||||
'rb': <SiRuby className="w-6 h-6 text-red-500" />,
|
||||
'go': <SiGo className="w-6 h-6 text-cyan-500" />,
|
||||
'rs': <SiRust className="w-6 h-6 text-orange-600" />,
|
||||
'html': <SiHtml5 className="w-6 h-6 text-orange-500" />,
|
||||
'css': <SiCss3 className="w-6 h-6 text-blue-500" />,
|
||||
'scss': <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
'sass': <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
'less': <SiLess className="w-6 h-6 text-blue-600" />,
|
||||
'json': <SiJson className="w-6 h-6 text-yellow-500" />,
|
||||
'xml': <SiXml className="w-6 h-6 text-orange-500" />,
|
||||
'yaml': <SiYaml className="w-6 h-6 text-red-400" />,
|
||||
'yml': <SiYaml className="w-6 h-6 text-red-400" />,
|
||||
'toml': <SiToml className="w-6 h-6 text-orange-400" />,
|
||||
'sql': <SiMysql className="w-6 h-6 text-blue-500" />,
|
||||
'sh': <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||
'bash': <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||
'zsh': <SiShell className="w-6 h-6 text-gray-700" />,
|
||||
'vue': <SiVuedotjs className="w-6 h-6 text-green-500" />,
|
||||
'svelte': <SiSvelte className="w-6 h-6 text-orange-500" />,
|
||||
'md': <SiMarkdown className="w-6 h-6 text-gray-600" />,
|
||||
'conf': <SiShell className="w-6 h-6 text-gray-600" />,
|
||||
'ini': <Code className="w-6 h-6 text-gray-600" />
|
||||
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
py: <SiPython className="w-6 h-6 text-blue-400" />,
|
||||
java: <SiOracle className="w-6 h-6 text-red-500" />,
|
||||
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
|
||||
c: <SiC className="w-6 h-6 text-blue-700" />,
|
||||
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
|
||||
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
|
||||
rb: <SiRuby className="w-6 h-6 text-red-500" />,
|
||||
go: <SiGo className="w-6 h-6 text-cyan-500" />,
|
||||
rs: <SiRust className="w-6 h-6 text-orange-600" />,
|
||||
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
|
||||
css: <SiCss3 className="w-6 h-6 text-blue-500" />,
|
||||
scss: <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
sass: <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
less: <SiLess className="w-6 h-6 text-blue-600" />,
|
||||
json: <SiJson className="w-6 h-6 text-yellow-500" />,
|
||||
xml: <SiXml className="w-6 h-6 text-orange-500" />,
|
||||
yaml: <SiYaml className="w-6 h-6 text-red-400" />,
|
||||
yml: <SiYaml className="w-6 h-6 text-red-400" />,
|
||||
toml: <SiToml className="w-6 h-6 text-orange-400" />,
|
||||
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
|
||||
sh: <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||
bash: <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||||
zsh: <SiShell className="w-6 h-6 text-gray-700" />,
|
||||
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
|
||||
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
|
||||
md: <SiMarkdown className="w-6 h-6 text-gray-600" />,
|
||||
conf: <SiShell className="w-6 h-6 text-gray-600" />,
|
||||
ini: <Code className="w-6 h-6 text-gray-600" />,
|
||||
};
|
||||
|
||||
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
|
||||
// 获取文件类型和图标
|
||||
function getFileType(filename: string): { type: string; icon: React.ReactNode; color: string } {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
function getFileType(filename: string): {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
} {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'];
|
||||
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm'];
|
||||
const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'];
|
||||
const textExts = ['txt', 'readme'];
|
||||
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'html', 'css', 'scss', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte', 'md'];
|
||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
|
||||
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
|
||||
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
|
||||
const textExts = ["txt", "readme"];
|
||||
const codeExts = [
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"py",
|
||||
"java",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"conf",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"vue",
|
||||
"svelte",
|
||||
"md",
|
||||
];
|
||||
|
||||
if (imageExts.includes(ext)) {
|
||||
return { type: 'image', icon: <ImageIcon className="w-6 h-6" />, color: 'text-green-500' };
|
||||
return {
|
||||
type: "image",
|
||||
icon: <ImageIcon className="w-6 h-6" />,
|
||||
color: "text-green-500",
|
||||
};
|
||||
} else if (videoExts.includes(ext)) {
|
||||
return { type: 'video', icon: <Film className="w-6 h-6" />, color: 'text-purple-500' };
|
||||
return {
|
||||
type: "video",
|
||||
icon: <Film className="w-6 h-6" />,
|
||||
color: "text-purple-500",
|
||||
};
|
||||
} else if (audioExts.includes(ext)) {
|
||||
return { type: 'audio', icon: <Music className="w-6 h-6" />, color: 'text-pink-500' };
|
||||
return {
|
||||
type: "audio",
|
||||
icon: <Music className="w-6 h-6" />,
|
||||
color: "text-pink-500",
|
||||
};
|
||||
} else if (textExts.includes(ext)) {
|
||||
return { type: 'text', icon: <FileText className="w-6 h-6" />, color: 'text-blue-500' };
|
||||
return {
|
||||
type: "text",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-blue-500",
|
||||
};
|
||||
} else if (codeExts.includes(ext)) {
|
||||
return { type: 'code', icon: getLanguageIcon(filename), color: 'text-yellow-500' };
|
||||
return {
|
||||
type: "code",
|
||||
icon: getLanguageIcon(filename),
|
||||
color: "text-yellow-500",
|
||||
};
|
||||
} else {
|
||||
return { type: 'unknown', icon: <FileIcon className="w-6 h-6" />, color: 'text-gray-500' };
|
||||
return {
|
||||
type: "unknown",
|
||||
icon: <FileIcon className="w-6 h-6" />,
|
||||
color: "text-gray-500",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取CodeMirror语言扩展
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
// 特殊文件名处理
|
||||
if (['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(baseName)) {
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName);
|
||||
}
|
||||
|
||||
// 根据扩展名映射
|
||||
const langMap: Record<string, string> = {
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'py': 'python',
|
||||
'java': 'java',
|
||||
'cpp': 'cpp',
|
||||
'c': 'c',
|
||||
'cs': 'csharp',
|
||||
'php': 'php',
|
||||
'rb': 'ruby',
|
||||
'go': 'go',
|
||||
'rs': 'rust',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'toml': 'toml',
|
||||
'sql': 'sql',
|
||||
'sh': 'shell',
|
||||
'bash': 'shell',
|
||||
'zsh': 'shell',
|
||||
'vue': 'vue',
|
||||
'svelte': 'svelte',
|
||||
'md': 'markdown',
|
||||
'conf': 'shell',
|
||||
'ini': 'properties'
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
py: "python",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "sass",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
md: "markdown",
|
||||
conf: "shell",
|
||||
ini: "properties",
|
||||
};
|
||||
|
||||
const language = langMap[ext];
|
||||
@@ -200,32 +260,36 @@ function getLanguageExtension(filename: string) {
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return 'Unknown size';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
if (!bytes) return "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function FileViewer({
|
||||
file,
|
||||
content = '',
|
||||
savedContent = '',
|
||||
content = "",
|
||||
savedContent = "",
|
||||
isLoading = false,
|
||||
isEditable = false,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onDownload
|
||||
onDownload,
|
||||
}: FileViewerProps) {
|
||||
const [editedContent, setEditedContent] = useState(content);
|
||||
const [originalContent, setOriginalContent] = useState(savedContent || content);
|
||||
const [originalContent, setOriginalContent] = useState(
|
||||
savedContent || content,
|
||||
);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
|
||||
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||||
const [showSearchPanel, setShowSearchPanel] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [replaceText, setReplaceText] = useState('');
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [replaceText, setReplaceText] = useState("");
|
||||
const [showReplacePanel, setShowReplacePanel] = useState(false);
|
||||
const [searchMatches, setSearchMatches] = useState<{ start: number; end: number }[]>([]);
|
||||
const [searchMatches, setSearchMatches] = useState<
|
||||
{ start: number; end: number }[]
|
||||
>([]);
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
@@ -236,9 +300,10 @@ export function FileViewer({
|
||||
|
||||
// 检查是否应该显示为文本
|
||||
const shouldShowAsText =
|
||||
fileTypeInfo.type === 'text' ||
|
||||
fileTypeInfo.type === 'code' ||
|
||||
(fileTypeInfo.type === 'unknown' && (forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||
fileTypeInfo.type === "text" ||
|
||||
fileTypeInfo.type === "code" ||
|
||||
(fileTypeInfo.type === "unknown" &&
|
||||
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||
|
||||
// 检查文件是否过大
|
||||
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||||
@@ -254,7 +319,7 @@ export function FileViewer({
|
||||
setHasChanges(content !== (savedContent || content));
|
||||
|
||||
// 如果是未知文件类型且文件较大,显示警告
|
||||
if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) {
|
||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||
setShowLargeFileWarning(true);
|
||||
} else {
|
||||
setShowLargeFileWarning(false);
|
||||
@@ -290,13 +355,13 @@ export function FileViewer({
|
||||
}
|
||||
|
||||
const matches: { start: number; end: number }[] = [];
|
||||
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(editedContent)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
// 避免无限循环
|
||||
if (match.index === regex.lastIndex) regex.lastIndex++;
|
||||
@@ -314,22 +379,32 @@ export function FileViewer({
|
||||
|
||||
const goToPrevMatch = () => {
|
||||
if (searchMatches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length);
|
||||
setCurrentMatchIndex(
|
||||
(prev) => (prev - 1 + searchMatches.length) % searchMatches.length,
|
||||
);
|
||||
};
|
||||
|
||||
// 替换功能
|
||||
const handleFindReplace = (findText: string, replaceWithText: string, replaceAll: boolean = false) => {
|
||||
const handleFindReplace = (
|
||||
findText: string,
|
||||
replaceWithText: string,
|
||||
replaceAll: boolean = false,
|
||||
) => {
|
||||
if (!findText) return;
|
||||
|
||||
let newContent = editedContent;
|
||||
if (replaceAll) {
|
||||
newContent = newContent.replace(new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replaceWithText);
|
||||
newContent = newContent.replace(
|
||||
new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
||||
replaceWithText,
|
||||
);
|
||||
} else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) {
|
||||
// 替换当前匹配项
|
||||
const match = searchMatches[currentMatchIndex];
|
||||
newContent = editedContent.substring(0, match.start) +
|
||||
replaceWithText +
|
||||
editedContent.substring(match.end);
|
||||
newContent =
|
||||
editedContent.substring(0, match.start) +
|
||||
replaceWithText +
|
||||
editedContent.substring(match.end);
|
||||
}
|
||||
|
||||
setEditedContent(newContent);
|
||||
@@ -374,11 +449,11 @@ export function FileViewer({
|
||||
"font-bold",
|
||||
isCurrentMatch
|
||||
? "text-red-600 bg-yellow-200"
|
||||
: "text-blue-800 bg-blue-100"
|
||||
: "text-blue-800 bg-blue-100",
|
||||
)}
|
||||
>
|
||||
{text.substring(match.start, match.end)}
|
||||
</span>
|
||||
</span>,
|
||||
);
|
||||
|
||||
lastIndex = match.end;
|
||||
@@ -428,7 +503,13 @@ export function FileViewer({
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
{file.modified && <span>Modified: {file.modified}</span>}
|
||||
<span className={cn("px-2 py-1 rounded-full text-xs", fileTypeInfo.color, "bg-muted")}>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs",
|
||||
fileTypeInfo.color,
|
||||
"bg-muted",
|
||||
)}
|
||||
>
|
||||
{fileTypeInfo.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -446,7 +527,6 @@ export function FileViewer({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Find
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -455,7 +535,6 @@ export function FileViewer({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Replace className="w-4 h-4" />
|
||||
Replace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -529,8 +608,9 @@ export function FileViewer({
|
||||
<span className="text-xs text-muted-foreground min-w-[3rem]">
|
||||
{searchMatches.length > 0
|
||||
? `${currentMatchIndex + 1}/${searchMatches.length}`
|
||||
: searchText ? '0/0' : ''
|
||||
}
|
||||
: searchText
|
||||
? "0/0"
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -538,7 +618,7 @@ export function FileViewer({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowSearchPanel(false);
|
||||
setSearchText('');
|
||||
setSearchText("");
|
||||
setSearchMatches([]);
|
||||
setCurrentMatchIndex(-1);
|
||||
}}
|
||||
@@ -557,7 +637,9 @@ export function FileViewer({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleFindReplace(searchText, replaceText, false)}
|
||||
onClick={() =>
|
||||
handleFindReplace(searchText, replaceText, false)
|
||||
}
|
||||
disabled={!searchText}
|
||||
>
|
||||
Replace
|
||||
@@ -584,19 +666,24 @@ export function FileViewer({
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">Large File Warning</h3>
|
||||
<h3 className="font-medium text-foreground mb-2">
|
||||
Large File Warning
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
This file is {formatFileSize(file.size)} in size, which may cause performance issues when opened as text.
|
||||
This file is {formatFileSize(file.size)} in size, which may
|
||||
cause performance issues when opened as text.
|
||||
</p>
|
||||
{isTooLarge ? (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
File is too large (> 10MB) and cannot be opened as text for security reasons.
|
||||
File is too large (> 10MB) and cannot be opened as
|
||||
text for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Do you want to continue opening this file as text? This may slow down your browser.
|
||||
Do you want to continue opening this file as text? This
|
||||
may slow down your browser.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -636,14 +723,14 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{/* 图片预览 */}
|
||||
{fileTypeInfo.type === 'image' && !showLargeFileWarning && (
|
||||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<img
|
||||
src={`data:image/*;base64,${content}`}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
(e.target as HTMLElement).style.display = "none";
|
||||
// Show error message instead
|
||||
}}
|
||||
/>
|
||||
@@ -653,7 +740,7 @@ export function FileViewer({
|
||||
{/* 文本和代码文件预览 */}
|
||||
{shouldShowAsText && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
{fileTypeInfo.type === 'code' ? (
|
||||
{fileTypeInfo.type === "code" ? (
|
||||
// 代码文件使用CodeMirror
|
||||
<div className="h-full">
|
||||
{searchText && searchMatches.length > 0 ? (
|
||||
@@ -661,8 +748,11 @@ export function FileViewer({
|
||||
<div className="h-full flex bg-muted">
|
||||
{/* 行号列 */}
|
||||
<div className="flex-shrink-0 bg-muted border-r border-border px-2 py-4 text-xs text-muted-foreground font-mono select-none">
|
||||
{editedContent.split('\n').map((_, index) => (
|
||||
<div key={index + 1} className="text-right leading-5 min-w-[2rem]">
|
||||
{editedContent.split("\n").map((_, index) => (
|
||||
<div
|
||||
key={index + 1}
|
||||
className="text-right leading-5 min-w-[2rem]"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
@@ -677,7 +767,11 @@ export function FileViewer({
|
||||
<CodeMirror
|
||||
value={editedContent}
|
||||
onChange={(value) => handleContentChange(value)}
|
||||
extensions={getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []}
|
||||
extensions={
|
||||
getLanguageExtension(file.name)
|
||||
? [getLanguageExtension(file.name)!]
|
||||
: []
|
||||
}
|
||||
theme="dark"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
@@ -688,7 +782,7 @@ export function FileViewer({
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false
|
||||
highlightSelectionMatches: false,
|
||||
}}
|
||||
className="h-full overflow-auto"
|
||||
readOnly={!isEditable}
|
||||
@@ -719,7 +813,7 @@ export function FileViewer({
|
||||
) : (
|
||||
// 只有非可编辑文件(媒体文件)才显示为只读
|
||||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||||
{editedContent || content || 'File is empty'}
|
||||
{editedContent || content || "File is empty"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -728,7 +822,7 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{/* 视频文件预览 */}
|
||||
{fileTypeInfo.type === 'video' && !showLargeFileWarning && (
|
||||
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<video
|
||||
controls
|
||||
@@ -741,10 +835,15 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{/* 音频文件预览 */}
|
||||
{fileTypeInfo.type === 'audio' && !showLargeFileWarning && (
|
||||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className={cn("w-24 h-24 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center", fileTypeInfo.color)}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-24 h-24 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center",
|
||||
fileTypeInfo.color,
|
||||
)}
|
||||
>
|
||||
<Music className="w-12 h-12" />
|
||||
</div>
|
||||
<audio
|
||||
@@ -759,27 +858,32 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{/* 未知文件类型 - 只在不能显示为文本且没有警告时显示 */}
|
||||
{fileTypeInfo.type === 'unknown' && !shouldShowAsText && !showLargeFileWarning && (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Cannot preview this file type</h3>
|
||||
<p className="text-sm mb-4">
|
||||
This file type is not supported for preview. You can download it to view in an external application.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download File
|
||||
</Button>
|
||||
)}
|
||||
{fileTypeInfo.type === "unknown" &&
|
||||
!shouldShowAsText &&
|
||||
!showLargeFileWarning && (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
Cannot preview this file type
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
This file type is not supported for preview. You can download
|
||||
it to view in an external application.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download File
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
@@ -787,10 +891,12 @@ export function FileViewer({
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{file.path}</span>
|
||||
{hasChanges && (
|
||||
<span className="text-orange-600 font-medium">● Unsaved changes</span>
|
||||
<span className="text-orange-600 font-medium">
|
||||
● Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { useWindowManager } from './WindowManager';
|
||||
import { downloadSSHFile, readSSHFile, writeSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow";
|
||||
import { FileViewer } from "./FileViewer";
|
||||
import { useWindowManager } from "./WindowManager";
|
||||
import {
|
||||
downloadSSHFile,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -25,7 +31,7 @@ interface SSHHost {
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: 'password' | 'key';
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
@@ -46,27 +52,34 @@ export function FileWindow({
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 100,
|
||||
initialY = 100
|
||||
initialY = 100,
|
||||
}: FileWindowProps) {
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, updateWindow, windows } = useWindowManager();
|
||||
const {
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
focusWindow,
|
||||
updateWindow,
|
||||
windows,
|
||||
} = useWindowManager();
|
||||
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>('');
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const currentWindow = windows.find(w => w.id === windowId);
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// 确保SSH连接有效
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
// 首先检查SSH连接状态
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
console.log('SSH connection status:', status);
|
||||
console.log("SSH connection status:", status);
|
||||
|
||||
if (!status.connected) {
|
||||
console.log('SSH not connected, attempting to reconnect...');
|
||||
console.log("SSH not connected, attempting to reconnect...");
|
||||
|
||||
// 重新建立连接
|
||||
await connectSSH(sshSessionId, {
|
||||
@@ -79,13 +92,13 @@ export function FileWindow({
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
|
||||
console.log('SSH reconnection successful');
|
||||
console.log("SSH reconnection successful");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('SSH connection check/reconnect failed:', error);
|
||||
console.log("SSH connection check/reconnect failed:", error);
|
||||
// 即使连接失败也尝试继续,让具体的API调用报错
|
||||
throw error;
|
||||
}
|
||||
@@ -94,7 +107,7 @@ export function FileWindow({
|
||||
// 加载文件内容
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== 'file') return;
|
||||
if (file.type !== "file") return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -103,7 +116,7 @@ export function FileWindow({
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const fileContent = response.content || '';
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent); // 初始化待保存内容
|
||||
|
||||
@@ -116,22 +129,54 @@ export function FileWindow({
|
||||
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
|
||||
const mediaExtensions = [
|
||||
// 图片文件
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff', 'ico',
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"webp",
|
||||
"tiff",
|
||||
"ico",
|
||||
// 音频文件
|
||||
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
"aac",
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
// 视频文件
|
||||
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v',
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
// 压缩文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
// 二进制文件
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'iso'
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
"dylib",
|
||||
"bin",
|
||||
"iso",
|
||||
];
|
||||
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
// 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑
|
||||
setIsEditable(!mediaExtensions.includes(extension || ''));
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load file:', error);
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
// 检查是否是大文件错误
|
||||
const errorData = error?.response?.data;
|
||||
@@ -139,11 +184,18 @@ export function FileWindow({
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
duration: 10000, // 10 seconds for important message
|
||||
});
|
||||
} else if (error.message?.includes('connection') || error.message?.includes('established')) {
|
||||
} else if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to load file: ${error.message || errorData?.error || 'Unknown error'}`);
|
||||
toast.error(
|
||||
`Failed to load file: ${error.message || errorData?.error || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -163,7 +215,7 @@ export function FileWindow({
|
||||
|
||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||
setContent(newContent);
|
||||
setPendingContent(''); // 清除待保存内容
|
||||
setPendingContent(""); // 清除待保存内容
|
||||
|
||||
// 清除自动保存定时器
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -171,15 +223,20 @@ export function FileWindow({
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
toast.success('File saved successfully');
|
||||
toast.success("File saved successfully");
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save file:', error);
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
if (error.message?.includes('connection') || error.message?.includes('established')) {
|
||||
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to save file: ${error.message || 'Unknown error'}`);
|
||||
toast.error(`Failed to save file: ${error.message || "Unknown error"}`);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -198,12 +255,12 @@ export function FileWindow({
|
||||
// 设置新的1分钟自动保存定时器
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
console.log('Auto-saving file...');
|
||||
console.log("Auto-saving file...");
|
||||
await handleSave(newContent);
|
||||
toast.success('File auto-saved');
|
||||
toast.success("File auto-saved");
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error);
|
||||
toast.error('Auto-save failed');
|
||||
console.error("Auto-save failed:", error);
|
||||
toast.error("Auto-save failed");
|
||||
}
|
||||
}, 60000); // 1分钟 = 60000毫秒
|
||||
};
|
||||
@@ -233,10 +290,12 @@ export function FileWindow({
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
@@ -244,16 +303,23 @@ export function FileWindow({
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('File downloaded successfully');
|
||||
toast.success("File downloaded successfully");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to download file:', error);
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
// 如果是连接错误,提供更明确的错误信息
|
||||
if (error.message?.includes('connection') || error.message?.includes('established')) {
|
||||
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to download file: ${error.message || 'Unknown error'}`);
|
||||
toast.error(
|
||||
`Failed to download file: ${error.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -307,4 +373,4 @@ export function FileWindow({
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { Terminal } from '../../Terminal/Terminal';
|
||||
import { useWindowManager } from './WindowManager';
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow";
|
||||
import { Terminal } from "../../Terminal/Terminal";
|
||||
import { useWindowManager } from "./WindowManager";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -12,7 +12,7 @@ interface SSHHost {
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: 'password' | 'key';
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
@@ -32,12 +32,13 @@ export function TerminalWindow({
|
||||
initialPath,
|
||||
initialX = 200,
|
||||
initialY = 150,
|
||||
executeCommand
|
||||
executeCommand,
|
||||
}: TerminalWindowProps) {
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
// 获取当前窗口状态
|
||||
const currentWindow = windows.find(w => w.id === windowId);
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
console.warn(`Window with id ${windowId} not found`);
|
||||
return null;
|
||||
@@ -62,8 +63,8 @@ export function TerminalWindow({
|
||||
const terminalTitle = executeCommand
|
||||
? `运行 - ${hostConfig.name}:${executeCommand}`
|
||||
: initialPath
|
||||
? `终端 - ${hostConfig.name}:${initialPath}`
|
||||
: `终端 - ${hostConfig.name}`;
|
||||
? `终端 - ${hostConfig.name}:${initialPath}`
|
||||
: `终端 - ${hostConfig.name}`;
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
@@ -90,4 +91,4 @@ export function TerminalWindow({
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
@@ -19,7 +19,7 @@ interface WindowManagerProps {
|
||||
|
||||
interface WindowManagerContextType {
|
||||
windows: WindowInstance[];
|
||||
openWindow: (window: Omit<WindowInstance, 'id' | 'zIndex'>) => string;
|
||||
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
|
||||
closeWindow: (id: string) => void;
|
||||
minimizeWindow: (id: string) => void;
|
||||
maximizeWindow: (id: string) => void;
|
||||
@@ -27,7 +27,8 @@ interface WindowManagerContextType {
|
||||
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
|
||||
}
|
||||
|
||||
const WindowManagerContext = React.createContext<WindowManagerContextType | null>(null);
|
||||
const WindowManagerContext =
|
||||
React.createContext<WindowManagerContextType | null>(null);
|
||||
|
||||
export function WindowManager({ children }: WindowManagerProps) {
|
||||
const [windows, setWindows] = useState<WindowInstance[]>([]);
|
||||
@@ -35,65 +36,73 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
const windowCounter = useRef(0);
|
||||
|
||||
// 打开新窗口
|
||||
const openWindow = useCallback((windowData: Omit<WindowInstance, 'id' | 'zIndex'>) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
const openWindow = useCallback(
|
||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
|
||||
// 计算偏移位置,避免窗口完全重叠
|
||||
const offset = (windows.length % 5) * 30;
|
||||
const adjustedX = windowData.x + offset;
|
||||
const adjustedY = windowData.y + offset;
|
||||
// 计算偏移位置,避免窗口完全重叠
|
||||
const offset = (windows.length % 5) * 30;
|
||||
const adjustedX = windowData.x + offset;
|
||||
const adjustedY = windowData.y + offset;
|
||||
|
||||
const newWindow: WindowInstance = {
|
||||
...windowData,
|
||||
id,
|
||||
zIndex,
|
||||
x: adjustedX,
|
||||
y: adjustedY,
|
||||
};
|
||||
const newWindow: WindowInstance = {
|
||||
...windowData,
|
||||
id,
|
||||
zIndex,
|
||||
x: adjustedX,
|
||||
y: adjustedY,
|
||||
};
|
||||
|
||||
setWindows(prev => [...prev, newWindow]);
|
||||
return id;
|
||||
}, [windows.length]);
|
||||
setWindows((prev) => [...prev, newWindow]);
|
||||
return id;
|
||||
},
|
||||
[windows.length],
|
||||
);
|
||||
|
||||
// 关闭窗口
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
setWindows(prev => prev.filter(w => w.id !== id));
|
||||
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
// 最小化窗口
|
||||
const minimizeWindow = useCallback((id: string) => {
|
||||
setWindows(prev => prev.map(w =>
|
||||
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w
|
||||
));
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 最大化/还原窗口
|
||||
const maximizeWindow = useCallback((id: string) => {
|
||||
setWindows(prev => prev.map(w =>
|
||||
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w
|
||||
));
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 聚焦窗口 (置于顶层)
|
||||
const focusWindow = useCallback((id: string) => {
|
||||
setWindows(prev => {
|
||||
const targetWindow = prev.find(w => w.id === id);
|
||||
setWindows((prev) => {
|
||||
const targetWindow = prev.find((w) => w.id === id);
|
||||
if (!targetWindow) return prev;
|
||||
|
||||
const newZIndex = ++nextZIndex.current;
|
||||
return prev.map(w =>
|
||||
w.id === id ? { ...w, zIndex: newZIndex } : w
|
||||
);
|
||||
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 更新窗口属性
|
||||
const updateWindow = useCallback((id: string, updates: Partial<WindowInstance>) => {
|
||||
setWindows(prev => prev.map(w =>
|
||||
w.id === id ? { ...w, ...updates } : w
|
||||
));
|
||||
}, []);
|
||||
const updateWindow = useCallback(
|
||||
(id: string, updates: Partial<WindowInstance>) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue: WindowManagerContextType = {
|
||||
windows,
|
||||
@@ -110,9 +119,9 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
{children}
|
||||
{/* 渲染所有窗口 */}
|
||||
<div className="window-container">
|
||||
{windows.map(window => (
|
||||
{windows.map((window) => (
|
||||
<div key={window.id}>
|
||||
{typeof window.component === 'function'
|
||||
{typeof window.component === "function"
|
||||
? window.component(window.id)
|
||||
: window.component}
|
||||
</div>
|
||||
@@ -126,7 +135,7 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
export function useWindowManager() {
|
||||
const context = React.useContext(WindowManagerContext);
|
||||
if (!context) {
|
||||
throw new Error('useWindowManager must be used within a WindowManager');
|
||||
throw new Error("useWindowManager must be used within a WindowManager");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user