From 9b023b865934bb352cad05589f003144db309ea8 Mon Sep 17 00:00:00 2001 From: zhangzheng <826193720@qq.com> Date: Wed, 12 Aug 2026 09:43:01 +0800 Subject: [PATCH] =?UTF-8?q?fix=20bugs=201=E7=B2=98=E8=B4=B4=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=97=B6=EF=BC=8C=E6=8A=A5=E6=9D=83=E9=99=90=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=97=B6=E6=B2=A1=E6=9C=89=E9=80=82=E9=85=8D=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=EF=BC=9B2markdown=E5=A4=A7=E4=BA=8E2M?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B0=B1=E7=94=A8=E7=BC=96=E8=BE=91=E5=8C=BA?= =?UTF-8?q?=E6=89=93=E5=BC=80=E4=BA=86=EF=BC=8C=E8=80=8C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?md=E7=9A=84tab=EF=BC=8C=E8=80=8C=E4=B8=94=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E6=85=A2=E6=96=87=E4=BB=B6=E6=AF=94=E8=BE=83?= =?UTF-8?q?=E5=A4=A7=E7=9A=84=E8=AF=9D=EF=BC=9B3=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E5=99=A8=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=8E=EF=BC=8C=E5=9C=A8=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=91=E7=A9=BA=E7=99=BD=E5=A4=84=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E7=B2=98=E8=B4=B4=EF=BC=8C=E5=8F=8D?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E6=AC=A1=E6=93=8D=E4=BD=9C=E5=90=8E=EF=BC=8C?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=E5=A4=84=E5=8F=AA=E6=9C=89=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E9=80=89=E9=A1=B9=EF=BC=9B4=E8=A7=A3=E5=8E=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E5=90=8E=E7=9B=B4=E6=8E=A5=E5=92=8C=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E6=96=87=E4=BB=B6=E5=A4=B9=E5=90=88=E5=B9=B6=E4=BA=86?= =?UTF-8?q?=EF=BC=8C=E6=B2=A1=E6=9C=89=E7=94=9F=E6=88=90=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/desktop/src/api/commands.rs | 47 ++---- .../src/app/components/panels/FilesPanel.tsx | 20 ++- .../components/Markdown/Markdown.tsx | 12 +- .../Markdown/MarkdownMathRenderer.tsx | 37 +++-- .../src/locales/en-US/panels/files.json | 2 + src/web-ui/src/locales/en-US/tools.json | 2 + .../src/locales/zh-CN/panels/files.json | 2 + src/web-ui/src/locales/zh-CN/tools.json | 2 + .../src/locales/zh-TW/panels/files.json | 2 + src/web-ui/src/locales/zh-TW/tools.json | 2 + .../core/ContextMenuManager.ts | 2 +- .../tools/editor/components/CodeEditor.scss | 9 +- .../tools/editor/components/CodeEditor.tsx | 46 +++--- .../editor/components/MarkdownEditor.scss | 14 ++ .../editor/components/MarkdownEditor.test.tsx | 5 +- .../editor/components/MarkdownEditor.tsx | 136 +++++++++------- .../tools/editor/components/PlanViewer.tsx | 17 +- .../meditor/components/MEditor.test.tsx | 48 ++++++ .../editor/meditor/components/MEditor.tsx | 14 +- .../editor/meditor/components/Preview.test.ts | 73 +++++++++ .../editor/meditor/components/Preview.tsx | 153 +++++++++++++++++- .../tools/editor/meditor/hooks/useEditor.ts | 5 +- .../src/tools/editor/meditor/types/index.ts | 2 + .../services/workspaceFileTransfer.test.ts | 13 ++ .../services/workspaceFileTransfer.ts | 12 +- 25 files changed, 523 insertions(+), 154 deletions(-) create mode 100644 src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx create mode 100644 src/web-ui/src/tools/editor/meditor/components/Preview.test.ts diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index d0af494793..70dbeeaa36 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -3467,20 +3467,10 @@ fn build_remote_path_exists_command(parent: &str, candidate: &str) -> String { ) } -fn build_remote_extraction_destination_available_command( - parent: &str, - candidate: &str, - allow_existing_directory: bool, -) -> String { +fn build_remote_extraction_destination_available_command(parent: &str, candidate: &str) -> String { let parent = shell_quote_posix(parent); let candidate = shell_quote_posix(&format!("./{}", candidate)); - if allow_existing_directory { - format!( - "cd -- {parent} || exit 2; if [ -L {candidate} ]; then exit 1; fi; [ -d {candidate} ] || [ ! -e {candidate} ]" - ) - } else { - format!("cd -- {parent} || exit 2; test ! -e {candidate} && test ! -L {candidate}") - } + format!("cd -- {parent} || exit 2; test ! -e {candidate} && test ! -L {candidate}") } fn next_available_local_archive_path(parent: &Path, base_name: &str, extension: &str) -> PathBuf { @@ -3719,11 +3709,8 @@ pub async fn decompress_path( let remote_dest_dir_name = loop { let candidate = extraction_destination_name_with_suffix(&remote_dest_base_name, remote_dest_suffix); - let check_command = build_remote_extraction_destination_available_command( - &remote_parent, - &candidate, - remote_dest_suffix == 0, - ); + let check_command = + build_remote_extraction_destination_available_command(&remote_parent, &candidate); let (stdout, stderr, code) = manager .execute_command(cid, &check_command) .await @@ -4268,13 +4255,6 @@ fn next_available_local_extraction_path(parent: &Path, base_name: &str) -> Resul for suffix in 0.. { let candidate = parent.join(extraction_destination_name_with_suffix(base_name, suffix)); match std::fs::symlink_metadata(&candidate) { - Ok(metadata) - if suffix == 0 - && metadata.file_type().is_dir() - && !metadata.file_type().is_symlink() => - { - return Ok(candidate); - } Ok(_) => continue, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate), Err(error) => { @@ -4336,7 +4316,7 @@ mod archive_tests { } #[test] - fn local_extraction_path_avoids_existing_files_but_reuses_base_directory() { + fn local_extraction_path_uses_incrementing_suffixes_for_any_existing_item() { let temp = tempfile::tempdir().expect("create temp dir"); std::fs::write(temp.path().join("aa"), "source").expect("write source file"); std::fs::create_dir(temp.path().join("aa (1)")).expect("create existing directory"); @@ -4347,11 +4327,11 @@ mod archive_tests { temp.path().join("aa (2)") ); - std::fs::create_dir(temp.path().join("project")).expect("create merge destination"); + std::fs::create_dir(temp.path().join("project")).expect("create existing directory"); assert_eq!( next_available_local_extraction_path(temp.path(), "project") - .expect("reuse existing destination directory"), - temp.path().join("project") + .expect("choose unique extraction destination"), + temp.path().join("project (1)") ); } @@ -4368,17 +4348,14 @@ mod archive_tests { let command = build_remote_extraction_destination_available_command( "/home/work tree", "中 文@!# (2)", - false, ); assert!(command.contains("test ! -e './中 文@!# (2)'")); assert!(command.contains("test ! -L './中 文@!# (2)'")); - let command = build_remote_extraction_destination_available_command( - "/home/work tree", - "中 文@!#", - true, - ); - assert!(command.contains("[ -d './中 文@!#' ] || [ ! -e './中 文@!#' ]")); + let command = + build_remote_extraction_destination_available_command("/home/work tree", "中 文@!#"); + assert!(command.contains("test ! -e './中 文@!#'")); + assert!(command.contains("test ! -L './中 文@!#'")); } #[test] diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index 1e7d2c4a5c..6c1ac5a91a 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -41,6 +41,7 @@ import type { import { downloadWorkspaceFileToDisk, joinWorkspaceTargetPath, + isFilePermissionError, normalizeWorkspaceTargetDirectory, pasteClipboardFilesToWorkspaceDirectory, resolvePasteTargetDirectory, @@ -79,6 +80,17 @@ function isAlreadyExistsError(error: unknown): boolean { return /already exists|file exists|os error 17|os error 183|EEXIST/i.test(message); } +function getPasteErrorMessage( + t: (key: string, options?: Record) => string, + name: string, + error: string, +): string { + return t('notifications.pasteErrorDetail', { + name, + error: isFilePermissionError(error) ? t('notifications.pastePermissionDenied') : error, + }); +} + /** Format a byte-per-second speed value for display, e.g. "1.4 MB/s". */ function formatSpeed(bytesPerSec: number): string { return `${formatBytes(bytesPerSec)}/s`; @@ -737,7 +749,7 @@ const FilesPanel: React.FC = ({ if (result.failedFiles.length > 0) { const failedNames = result.failedFiles.map((entry) => { const name = entry.path.split(/[/\\]/).pop() || entry.path; - return `${name}: ${entry.error}`; + return getPasteErrorMessage(t, name, entry.error); }).join('\n'); notification.error( t('notifications.pasteFailed', { count: result.failedFiles.length }) + `:\n${failedNames}`, @@ -758,7 +770,11 @@ const FilesPanel: React.FC = ({ if (cancelledTransferIdsRef.current.has(id)) { cancelledTransferIdsRef.current.delete(id); } else { - notification.error(t('notifications.pasteFailed', { count: 1 })); + notification.error( + isFilePermissionError(error) + ? t('notifications.pastePermissionDenied') + : t('notifications.pasteFailed', { count: 1 }) + ); } } }, [ diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 9e30ae3f7c..214ca27e0f 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -8,6 +8,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; +import type { PluggableList } from 'unified'; import { visit } from 'unist-util-visit'; import { i18nService } from '@/infrastructure/i18n'; import { MermaidBlock } from './MermaidBlock'; @@ -255,6 +256,9 @@ const sanitizeSchema = { }, }; +const MARKDOWN_REMARK_PLUGINS: PluggableList = [remarkGfm, remarkAutolinkComputerFileLinks]; +const MARKDOWN_REHYPE_PLUGINS: PluggableList = [rehypeRaw, [rehypeSanitize, sanitizeSchema]]; + function remarkAutolinkComputerFileLinks() { return (tree: any) => { visit(tree, 'text', (node: any, index: number | undefined, parent: any) => { @@ -1420,15 +1424,15 @@ export const Markdown = React.memo(({ ]); const wrapperClassName = `markdown-renderer ${className}`.trim(); - const basicMarkdownRenderer = ( + const basicMarkdownRenderer = useMemo(() => ( {markdownContent} - ); + ), [components, markdownContent]); return (
diff --git a/src/web-ui/src/component-library/components/Markdown/MarkdownMathRenderer.tsx b/src/web-ui/src/component-library/components/Markdown/MarkdownMathRenderer.tsx index 4d9f94fa8e..e469a8f3c1 100644 --- a/src/web-ui/src/component-library/components/Markdown/MarkdownMathRenderer.tsx +++ b/src/web-ui/src/component-library/components/Markdown/MarkdownMathRenderer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -7,7 +7,7 @@ import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import type { Options as RehypeSanitizeOptions } from 'rehype-sanitize'; -import type { Pluggable } from 'unified'; +import type { Pluggable, PluggableList } from 'unified'; import 'katex/dist/katex.min.css'; interface MarkdownMathRendererProps { @@ -22,16 +22,27 @@ export const MarkdownMathRenderer: React.FC = ({ components, sanitizeSchema, remarkAutolinkComputerFileLinks, -}) => ( -
- - {markdownContent} - -
-); +}) => { + const remarkPlugins = useMemo( + () => [remarkGfm, remarkMath, remarkAutolinkComputerFileLinks], + [remarkAutolinkComputerFileLinks], + ); + const rehypePlugins = useMemo( + () => [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex], + [sanitizeSchema], + ); + + return ( +
+ + {markdownContent} + +
+ ); +}; export default MarkdownMathRenderer; diff --git a/src/web-ui/src/locales/en-US/panels/files.json b/src/web-ui/src/locales/en-US/panels/files.json index 02a9567e1a..eada937da7 100644 --- a/src/web-ui/src/locales/en-US/panels/files.json +++ b/src/web-ui/src/locales/en-US/panels/files.json @@ -148,6 +148,8 @@ "pasteSuccessFolders": "Successfully pasted {{count}} folders", "pasteSuccessItems": "Successfully pasted {{count}} items", "pasteFailed": "{{count}} files failed to paste", + "pastePermissionDenied": "Permission denied. Check the source file and destination folder permissions.", + "pasteErrorDetail": "{{name}}: {{error}}", "pasteNoFiles": "No files in clipboard", "pastingFiles": "Pasting {{count}} files to {{target}}...", "openExplorerFailed": "Failed to open file explorer: {{error}}", diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index 9f308d9c8e..ebdb1f4609 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -32,8 +32,10 @@ "retry": "Retry", "loading": "Loading...", "loadFailed": "Load failed", + "unsupportedFileType": "Unsupported file type", "largeFilePreview": "The file is too large; showing the first 8 MB as a read-only preview", "fileNotFound": "File not found", + "fileTooLarge": "File is too large; only text files smaller than 15 MB can be opened", "permissionDenied": "Permission denied", "networkError": "Network error", "saveFailedWithMessage": "Failed to save file: {{message}}", diff --git a/src/web-ui/src/locales/zh-CN/panels/files.json b/src/web-ui/src/locales/zh-CN/panels/files.json index d4c88745f0..ac368b40bb 100644 --- a/src/web-ui/src/locales/zh-CN/panels/files.json +++ b/src/web-ui/src/locales/zh-CN/panels/files.json @@ -148,6 +148,8 @@ "pasteSuccessFolders": "成功粘贴 {{count}} 个文件夹", "pasteSuccessItems": "成功粘贴 {{count}} 项", "pasteFailed": "{{count}} 个文件粘贴失败", + "pastePermissionDenied": "没有权限。请检查源文件和目标文件夹的访问权限。", + "pasteErrorDetail": "{{name}}:{{error}}", "pasteNoFiles": "剪贴板中没有文件", "pastingFiles": "正在粘贴 {{count}} 个文件到 {{target}}...", "openExplorerFailed": "打开文件管理器失败: {{error}}", diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index 8938ab7d48..a4355971c5 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -32,8 +32,10 @@ "retry": "重试", "loading": "加载中...", "loadFailed": "加载失败", + "unsupportedFileType": "不支持的文件类型", "largeFilePreview": "文件过大,已打开前 8 MB 只读预览", "fileNotFound": "文件不存在", + "fileTooLarge": "文件过大,仅支持打开小于 15 MB 的文本文件", "permissionDenied": "权限不足", "networkError": "网络错误", "saveFailedWithMessage": "保存文件失败: {{message}}", diff --git a/src/web-ui/src/locales/zh-TW/panels/files.json b/src/web-ui/src/locales/zh-TW/panels/files.json index ba8af2f6dc..c31d701a8c 100644 --- a/src/web-ui/src/locales/zh-TW/panels/files.json +++ b/src/web-ui/src/locales/zh-TW/panels/files.json @@ -148,6 +148,8 @@ "pasteSuccessFolders": "成功粘貼 {{count}} 個資料夾", "pasteSuccessItems": "成功粘貼 {{count}} 項", "pasteFailed": "{{count}} 個檔案粘貼失敗", + "pastePermissionDenied": "沒有權限。請檢查來源檔案和目標資料夾的存取權限。", + "pasteErrorDetail": "{{name}}:{{error}}", "pasteNoFiles": "剪貼板中沒有檔案", "pastingFiles": "正在粘貼 {{count}} 個檔案到 {{target}}...", "openExplorerFailed": "開啟檔案管理器失敗: {{error}}", diff --git a/src/web-ui/src/locales/zh-TW/tools.json b/src/web-ui/src/locales/zh-TW/tools.json index 94caee0265..d0e6721f82 100644 --- a/src/web-ui/src/locales/zh-TW/tools.json +++ b/src/web-ui/src/locales/zh-TW/tools.json @@ -32,8 +32,10 @@ "retry": "重試", "loading": "載入中...", "loadFailed": "載入失敗", + "unsupportedFileType": "不支援的檔案類型", "largeFilePreview": "檔案過大,已開啟前 8 MB 唯讀預覽", "fileNotFound": "檔案不存在", + "fileTooLarge": "檔案過大,僅支援開啟小於 15 MB 的文字檔案", "permissionDenied": "權限不足", "networkError": "網絡錯誤", "saveFailedWithMessage": "儲存檔案失敗: {{message}}", diff --git a/src/web-ui/src/shared/context-menu-system/core/ContextMenuManager.ts b/src/web-ui/src/shared/context-menu-system/core/ContextMenuManager.ts index 8bc74a8176..ef17dae9b7 100644 --- a/src/web-ui/src/shared/context-menu-system/core/ContextMenuManager.ts +++ b/src/web-ui/src/shared/context-menu-system/core/ContextMenuManager.ts @@ -111,7 +111,7 @@ export class ContextMenuManager { async show(event: MouseEvent | React.MouseEvent): Promise { if (this.isShowing) { - this.hide(); + await this.hide(); } this.isShowing = true; diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.scss b/src/web-ui/src/tools/editor/components/CodeEditor.scss index ccf68f43e2..abbb31afd6 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.scss +++ b/src/web-ui/src/tools/editor/components/CodeEditor.scss @@ -125,11 +125,18 @@ $_editor-padding-desktop: 32px; flex: 1; min-height: 0; position: relative; - + > div { height: 100%; } } + + &.is-error { + > .code-editor-tool__content, + > .editor-status-bar { + display: none; + } + } // Overlay Styles &__loading-overlay, diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 203f20546c..846c91a373 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -19,7 +19,7 @@ import { globalEventBus } from '@/infrastructure/event-bus'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { EditorConfig as EditorConfigType } from '@/infrastructure/config/types'; import { CubeLoading } from '@/component-library'; -import { getMonacoLanguage } from '@/infrastructure/language-detection'; +import { getFileIconType, getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; import { sendDebugProbe } from '@/shared/utils/debugProbe'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; @@ -102,9 +102,8 @@ export interface CodeEditorProps { autoSaveDelayMs?: number; } +export const MAX_TEXT_FILE_SIZE_BYTES = 15 * 1024 * 1024; const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB -const LARGE_FILE_FULL_LOAD_LIMIT_BYTES = 100 * 1024 * 1024; -const LARGE_FILE_PREVIEW_BYTES = 8 * 1024 * 1024; const LARGE_FILE_MAX_LINE_LENGTH = 20000; const LARGE_FILE_RENDER_LINE_LIMIT = 10000; const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000; @@ -249,6 +248,7 @@ const CodeEditor: React.FC = ({ minimap: { enabled: showMinimap, side: 'right', size: 'proportional' } }); const isMemoryContent = initialContent !== undefined; + const isUnsupportedFileType = !isMemoryContent && ['archive', 'binary'].includes(getFileIconType(filePath)); const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 }); const [selection, setSelection] = useState({ chars: 0, lines: 0 }); const [statusBarPopover, setStatusBarPopover] = useState(null); @@ -1509,6 +1509,12 @@ const CodeEditor: React.FC = ({ return; } + if (isUnsupportedFileType) { + setLoading(false); + setError(t('editor.common.unsupportedFileType')); + return; + } + if (isMemoryContent) { const fileContent = initialContent ?? ''; setLoading(true); @@ -1570,15 +1576,15 @@ const CodeEditor: React.FC = ({ if (isFileMissingFromMetadata(fileInfoBefore)) { throw new Error('File does not exist'); } - let fileSizeBytes = typeof fileInfoBefore?.size === 'number' + const fileSizeBytes = typeof fileInfoBefore?.size === 'number' ? fileInfoBefore.size : undefined; - const shouldPreview = !readEncoding && typeof fileSizeBytes === 'number' - && fileSizeBytes > LARGE_FILE_FULL_LOAD_LIMIT_BYTES; - const fileContent = shouldPreview - ? await workspaceAPI.readFileContentPrefix(filePath, LARGE_FILE_PREVIEW_BYTES) - : await workspaceAPI.readFileContent(filePath, readEncoding); - setLargeFilePreview(shouldPreview); + if (typeof fileSizeBytes === 'number' && fileSizeBytes >= MAX_TEXT_FILE_SIZE_BYTES) { + setError(t('editor.common.fileTooLarge')); + return; + } + const fileContent = await workspaceAPI.readFileContent(filePath, readEncoding); + setLargeFilePreview(false); reportFileMissingFromDisk(false); try { const fileInfoAfter = await fetchFileMetadata(); @@ -1591,9 +1597,6 @@ const CodeEditor: React.FC = ({ diskVersionRef.current = v; } } - if (typeof fileInfoAfter?.size === 'number') { - fileSizeBytes = fileInfoAfter.size; - } } catch (err) { if (isLikelyFileNotFoundError(err)) { reportFileMissingFromDisk(true); @@ -1650,6 +1653,7 @@ const CodeEditor: React.FC = ({ filePath, initialContent, isMemoryContent, + isUnsupportedFileType, readEncoding, reportFileMissingFromDisk, t, @@ -2350,13 +2354,15 @@ const CodeEditor: React.FC = ({

{error}

- + {!isUnsupportedFileType && error !== t('editor.common.fileTooLarge') && ( + + )}
)} diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss index c401b0260a..1c5e68ff3d 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss @@ -91,6 +91,20 @@ flex: 1; } +.bitfun-markdown-editor__unsafe-body { + display: grid; +} + +.bitfun-markdown-editor__unsafe-panel { + min-width: 0; + min-height: 0; + grid-area: 1 / 1; + + &[hidden] { + display: none; + } +} + .bitfun-markdown-editor-loading { display: flex; align-items: center; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx index 4d2fab8e2a..928e617342 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx @@ -109,8 +109,9 @@ describe('MarkdownEditor', () => { expect(html).toContain('data-mode="preview"'); }); - it('uses source mode only above the rich markdown size limit', () => { - expect(shouldUseLargeMarkdownSourceMode(MARKDOWN_RICH_EDITOR_MAX_BYTES)).toBe(false); + it('uses source mode at and above the rich markdown size limit', () => { + expect(shouldUseLargeMarkdownSourceMode(MARKDOWN_RICH_EDITOR_MAX_BYTES - 1)).toBe(false); + expect(shouldUseLargeMarkdownSourceMode(MARKDOWN_RICH_EDITOR_MAX_BYTES)).toBe(true); expect(shouldUseLargeMarkdownSourceMode(MARKDOWN_RICH_EDITOR_MAX_BYTES + 1)).toBe(true); expect(shouldUseLargeMarkdownSourceMode(undefined)).toBe(false); }); diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index 662f6fe115..4c0aba3479 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -21,7 +21,7 @@ import { } from '@/infrastructure/peer-device/peerModeFlag'; import { CubeLoading, Button } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; -import CodeEditor from './CodeEditor'; +import CodeEditor, { MAX_TEXT_FILE_SIZE_BYTES } from './CodeEditor'; import { diskVersionFromMetadata, diskVersionsDiffer, @@ -40,10 +40,10 @@ import 'highlight.js/styles/github-dark.css'; const log = createLogger('MarkdownEditor'); const FILE_SYNC_POLL_INTERVAL_MS = 1000; -export const MARKDOWN_RICH_EDITOR_MAX_BYTES = 2 * 1024 * 1024; +export const MARKDOWN_RICH_EDITOR_MAX_BYTES = 512 * 1024; export function shouldUseLargeMarkdownSourceMode(fileSize?: number): boolean { - return typeof fileSize === 'number' && fileSize > MARKDOWN_RICH_EDITOR_MAX_BYTES; + return typeof fileSize === 'number' && fileSize >= MARKDOWN_RICH_EDITOR_MAX_BYTES; } function getPollOffsetMs(filePath: string): number { @@ -102,8 +102,10 @@ const MarkdownEditor: React.FC = ({ const [unsafeViewMode, setUnsafeViewMode] = useState<'source' | 'preview'>('source'); const [loading, setLoading] = useState(!!filePath); const [error, setError] = useState(null); + const [fileTooLarge, setFileTooLarge] = useState(false); const [copied, setCopied] = useState(false); const [useLargeFileSourceMode, setUseLargeFileSourceMode] = useState(false); + const [largeFilePreviewLoading, setLargeFilePreviewLoading] = useState(false); const [editability, setEditability] = useState(() => analyzeMarkdownEditability(initialContent)); const editorRef = useRef(null); const isUnmountedRef = useRef(false); @@ -191,6 +193,7 @@ const MarkdownEditor: React.FC = ({ setLoading(true); setError(null); + setFileTooLarge(false); try { const { workspaceAPI } = await import('@/infrastructure/api'); @@ -205,8 +208,26 @@ const MarkdownEditor: React.FC = ({ if (v) { diskVersionRef.current = v; } + if (typeof fileInfo.size === 'number' && fileInfo.size >= MAX_TEXT_FILE_SIZE_BYTES) { + setFileTooLarge(true); + setError(t('editor.common.fileTooLarge')); + return; + } if (shouldUseLargeMarkdownSourceMode(fileInfo.size)) { setUseLargeFileSourceMode(true); + if (!isUnmountedRef.current) { + const largeFileEditability = analyzeMarkdownEditability(''); + setUnsafeViewMode('source'); + setContent(''); + contentRef.current = ''; + setEditability({ + ...largeFileEditability, + canonicalMarkdown: '', + mode: 'unsafe', + }); + setHasChanges(false); + lastReportedDirtyRef.current = false; + } return; } } @@ -257,6 +278,35 @@ const MarkdownEditor: React.FC = ({ } }, [fetchFileMetadata, filePath, reportFileMissingFromDisk, t, toNormalizedMarkdown]); + const showLargeFilePreview = useCallback(async () => { + if (!useLargeFileSourceMode || !filePath) { + setUnsafeViewMode('preview'); + return; + } + + if (contentRef.current) { + setUnsafeViewMode('preview'); + return; + } + + setLargeFilePreviewLoading(true); + try { + const { workspaceAPI } = await import('@/infrastructure/api'); + const fileContent = await workspaceAPI.readFileContent(filePath); + if (!isUnmountedRef.current) { + contentRef.current = fileContent; + setContent(fileContent); + setUnsafeViewMode('preview'); + } + } catch (err) { + log.warn('Failed to load large Markdown preview', err); + } finally { + if (!isUnmountedRef.current) { + setLargeFilePreviewLoading(false); + } + } + }, [filePath, useLargeFileSourceMode]); + // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); useEffect(() => { @@ -662,7 +712,7 @@ const MarkdownEditor: React.FC = ({

{error}

- {filePath && ( + {filePath && !fileTooLarge && ( @@ -672,37 +722,6 @@ const MarkdownEditor: React.FC = ({ ); } - if (useLargeFileSourceMode && filePath) { - return ( -
- { - if (lastReportedDirtyRef.current === dirty) { - return; - } - lastReportedDirtyRef.current = dirty; - onContentChangeRef.current?.(_newContent, dirty); - }} - onSave={(savedContent) => { - lastReportedDirtyRef.current = false; - onContentChangeRef.current?.(savedContent, false); - }} - /> -
- ); - } - if (shouldUseSourcePreviewFallback) { return (
@@ -733,7 +752,8 @@ const MarkdownEditor: React.FC = ({ size="small" variant={unsafeViewMode === 'preview' ? 'primary' : 'secondary'} className="bitfun-markdown-editor__toolbar-button" - onClick={() => setUnsafeViewMode('preview')} + onClick={() => void showLargeFilePreview()} + disabled={largeFilePreviewLoading} aria-pressed={unsafeViewMode === 'preview'} > {t('editor.markdownEditor.preview')} @@ -759,7 +779,7 @@ const MarkdownEditor: React.FC = ({
- {unsafeViewMode === 'source' ? ( + + {unsafeViewMode === 'preview' && ( +
+ {largeFilePreviewLoading ? ( + + ) : ( + + )} +
)}
diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.tsx b/src/web-ui/src/tools/editor/components/PlanViewer.tsx index 34047df2fd..3ed05c7e67 100644 --- a/src/web-ui/src/tools/editor/components/PlanViewer.tsx +++ b/src/web-ui/src/tools/editor/components/PlanViewer.tsx @@ -5,6 +5,7 @@ import { Circle, ArrowRight, Check, XCircle, Loader2, CheckCircle, AlertCircle, import yaml from 'yaml'; import { MEditor } from '../meditor'; import type { EditorInstance } from '../meditor'; +import { MAX_TEXT_FILE_SIZE_BYTES } from './CodeEditor'; import { createLogger } from '@/shared/utils/logger'; import { CubeLoading, Button, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; @@ -60,6 +61,7 @@ const PlanViewer: React.FC = ({ const { t } = useI18n('tools'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [fileTooLarge, setFileTooLarge] = useState(false); const [planData, setPlanData] = useState(null); const [planContent, setPlanContent] = useState(''); // Initialize build state from the shared service to survive unmounts. @@ -128,8 +130,15 @@ const PlanViewer: React.FC = ({ setLoading(true); setError(null); + setFileTooLarge(false); try { + const fileInfo = await workspaceAPI.getFileMetadata(filePath); + if (typeof fileInfo?.size === 'number' && fileInfo.size >= MAX_TEXT_FILE_SIZE_BYTES) { + setFileTooLarge(true); + setError(t('editor.common.fileTooLarge')); + return; + } const content = await workspaceAPI.readFileContent(filePath); const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); @@ -755,9 +764,11 @@ ${JSON.stringify(simpleTodos, null, 2)}

{error}

- + {!fileTooLarge && ( + + )}
); diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx b/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx new file mode 100644 index 0000000000..418582e527 --- /dev/null +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { MEditor } from './MEditor'; + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: vi.fn(), +})); + +vi.mock('@/tools/editor/services/ActiveEditTargetService', () => ({ + activeEditTargetService: { + bindTarget: vi.fn(), + clearActiveTarget: vi.fn(), + setActiveTarget: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock('../utils/tiptapMarkdown', () => ({ + analyzeMarkdownEditability: () => ({ containsRenderOnlyBlocks: false }), +})); + +vi.mock('./Preview', () => ({ + Preview: ({ value }: { value: string }) =>
{value.length}
, +})); + +vi.mock('./TiptapEditor', () => ({ + TiptapEditor: () =>
, +})); + +vi.mock('./EditArea', () => ({ + EditArea: () =>