Cleanup files and improve file manager.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user