diff --git a/web/bun.lock b/web/bun.lock index 03123e8..bb126d8 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -6,10 +6,12 @@ "dependencies": { "@ant-design/icons": "5.x", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@monaco-editor/react": "^4.7.0", "@uiw/react-md-editor": "^4.0.8", "antd": "^5.27.0", "artplayer": "^5.2.5", "date-fns": "^4.1.0", + "monaco-editor": "^0.53.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -179,6 +181,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.5.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw=="], + + "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -273,6 +279,8 @@ "@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="], + "@types/trusted-types": ["@types/trusted-types@1.0.6", "", {}, "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="], @@ -641,6 +649,8 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "monaco-editor": ["monaco-editor@0.53.0", "", { "dependencies": { "@types/trusted-types": "^1.0.6" } }, "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -823,6 +833,8 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], diff --git a/web/package.json b/web/package.json index 4c9a1a6..0e89d0e 100644 --- a/web/package.json +++ b/web/package.json @@ -12,10 +12,12 @@ "dependencies": { "@ant-design/icons": "5.x", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@monaco-editor/react": "^4.7.0", "@uiw/react-md-editor": "^4.0.8", "antd": "^5.27.0", "artplayer": "^5.2.5", "date-fns": "^4.1.0", + "monaco-editor": "^0.53.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", diff --git a/web/src/api/vfs.ts b/web/src/api/vfs.ts index dddb8b2..298b8dd 100644 --- a/web/src/api/vfs.ts +++ b/web/src/api/vfs.ts @@ -38,7 +38,11 @@ export const vfsApi = { }); return request(`/fs/${encodeURI(trimmed)}?${params}`); }, - readFile: (path: string) => request(`/fs/file/${encodeURI(path.replace(/^\/+/, ''))}`), + readFile: async (path: string) => { + const enc = encodeURI(path.replace(/^\/+/, '')); + const resp = await request(`/fs/file/${enc}`, { rawResponse: true }); + return await (resp as Response).arrayBuffer(); + }, uploadFile: (fullPath: string, file: File | Blob) => { const fd = new FormData(); fd.append('file', file); diff --git a/web/src/apps/TextEditor/TextEditor.tsx b/web/src/apps/TextEditor/TextEditor.tsx index 53507b8..fa1448d 100644 --- a/web/src/apps/TextEditor/TextEditor.tsx +++ b/web/src/apps/TextEditor/TextEditor.tsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Layout, Spin, Button, Space, message } from 'antd'; import MDEditor from '@uiw/react-md-editor'; +import Editor from '@monaco-editor/react'; import type { AppComponentProps } from '../types'; import { vfsApi } from '../../api/vfs'; +import request from '../../api/client'; const { Header, Content } = Layout; @@ -11,20 +13,66 @@ export const TextEditorApp: React.FC = ({ filePath, entry, on const [saving, setSaving] = useState(false); const [content, setContent] = useState(''); const [initialContent, setInitialContent] = useState(''); + const [truncated, setTruncated] = useState(false); + const MAX_PREVIEW_BYTES = 1024 * 1024; // 1MB const isDirty = content !== initialContent; - - // 使用 ref 来持有最新的 onRequestClose 函数,避免它成为 effect 的依赖项 const onRequestCloseRef = useRef(onRequestClose); onRequestCloseRef.current = onRequestClose; + const ext = useMemo(() => entry.name.split('.').pop()?.toLowerCase() || '', [entry.name]); + const isMarkdown = ext === 'md' || ext === 'markdown'; + const monacoLanguage = useMemo(() => { + switch (ext) { + case 'json': + return 'json'; + case 'js': + return 'javascript'; + case 'ts': + return 'typescript'; + case 'html': + return 'html'; + case 'css': + return 'css'; + case 'py': + return 'python'; + case 'sh': + return 'shell'; + case 'yaml': + case 'yml': + return 'yaml'; + case 'xml': + return 'xml'; + case 'txt': + case 'log': + default: + return 'plaintext'; + } + }, [ext]); + useEffect(() => { const loadFile = async () => { try { setLoading(true); - const data = await vfsApi.readFile(filePath); - const text = typeof data === 'string' ? data : new TextDecoder().decode(data); - setContent(text); - setInitialContent(text); + setTruncated(false); + const shouldTruncate = (entry.size ?? 0) > MAX_PREVIEW_BYTES; + if (shouldTruncate) { + const enc = encodeURI(filePath.replace(/^\/+/, '')); + const resp = await request(`/fs/file/${enc}`, { + method: 'GET', + headers: { Range: `bytes=0-${MAX_PREVIEW_BYTES - 1}` }, + rawResponse: true, + }); + const buf = await (resp as Response).arrayBuffer(); + const text = new TextDecoder().decode(buf); + setContent(text); + setInitialContent(text); + setTruncated(true); + } else { + const data = await vfsApi.readFile(filePath); + const text = typeof data === 'string' ? data : new TextDecoder().decode(data); + setContent(text); + setInitialContent(text); + } } catch (error) { message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`); onRequestCloseRef.current(); @@ -33,9 +81,12 @@ export const TextEditorApp: React.FC = ({ filePath, entry, on } }; loadFile(); - }, [filePath]); // effect 只依赖 filePath,因此只在文件路径变化时执行一次 - + }, [filePath, entry.size]); const handleSave = useCallback(async () => { + if (truncated) { + message.warning('大文件仅预览前 1MB,已禁用保存'); + return; + } if (!isDirty) return; try { setSaving(true); @@ -48,7 +99,7 @@ export const TextEditorApp: React.FC = ({ filePath, entry, on } finally { setSaving(false); } - }, [content, filePath, isDirty]); + }, [content, filePath, isDirty, truncated]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -77,10 +128,10 @@ export const TextEditorApp: React.FC = ({ filePath, entry, on }} > - {entry.name} {isDirty && '*'} + {entry.name} {isDirty && '*'} {truncated && '(大文件仅预览前 1MB,编辑与保存已禁用)'} - @@ -91,12 +142,28 @@ export const TextEditorApp: React.FC = ({ filePath, entry, on ) : ( - setContent(val || '')} - height="100%" - preview="live" - /> + isMarkdown ? ( + setContent(val || '')} + height="100%" + preview={truncated ? 'preview' : 'live'} + /> + ) : ( + setContent(val || '')} + height="100%" + language={monacoLanguage} + options={{ + readOnly: truncated, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + fontSize: 13, + }} + /> + ) )}