Cleanup files and improve file manager.

This commit is contained in:
LukeGus
2025-09-18 00:32:56 -05:00
parent cb7bb3c864
commit 8afd84d96d
53 changed files with 6354 additions and 4736 deletions

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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 (&gt; 10MB) and cannot be opened as text for security reasons.
File is too large (&gt; 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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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;
}
}