From 70402f96f166d35da715f4e49e08043d720a8b91 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 17:30:38 +0800 Subject: [PATCH] fix(workspace): fall back for large markdown previews --- static/workspace.js | 35 ++++++++- .../test_issue2823_large_markdown_preview.py | 71 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue2823_large_markdown_preview.py diff --git a/static/workspace.js b/static/workspace.js index 5309addc..b4c37ae1 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -175,6 +175,8 @@ const HTML_EXTS = new Set(['.html','.htm']); const PDF_EXTS = new Set(['.pdf']); const AUDIO_EXTS = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']); const VIDEO_EXTS = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']); +const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024; +const MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500; // Binary formats that should download rather than preview const DOWNLOAD_EXTS = new Set([ '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp', @@ -186,6 +188,31 @@ const DOWNLOAD_EXTS = new Set([ function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; } +function markdownPreviewByteLength(content){ + const text=String(content||''); + if(typeof Blob==='function') return new Blob([text]).size; + if(typeof TextEncoder==='function') return new TextEncoder().encode(text).length; + return unescape(encodeURIComponent(text)).length; +} + +function markdownPreviewLineCount(content){ + const text=String(content||''); + if(!text) return 1; + return text.split('\n').length; +} + +function shouldRenderMarkdownPreviewAsPlainText(content){ + return markdownPreviewByteLength(content)>MD_PREVIEW_RICH_RENDER_MAX_BYTES + || markdownPreviewLineCount(content)>MD_PREVIEW_RICH_RENDER_MAX_LINES; +} + +function largeMarkdownPlainTextStatus(content){ + const bytes=markdownPreviewByteLength(content); + const lines=markdownPreviewLineCount(content); + const sizeLabel=bytes>=1024?`${Math.round(bytes/1024)} KB`:`${bytes} B`; + return `Large markdown file (${sizeLabel}, ${lines} lines) shown as plain text. Click Edit to view raw.`; +} + let _previewCurrentPath = ''; // relative path of currently previewed file let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video' let _previewDirty = false; // true when edits are unsaved @@ -317,8 +344,14 @@ async function openFile(path){ // Markdown: fetch text, render with renderMd, display as formatted HTML try{ const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); - showPreview('md'); _previewRawContent = data.content; + if(shouldRenderMarkdownPreviewAsPlainText(data.content)){ + showPreview('code'); + $('previewCode').textContent=data.content; + setStatus(largeMarkdownPlainTextStatus(data.content)); + return; + } + showPreview('md'); $('previewMd').innerHTML=renderMd(data.content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }catch(e){setStatus(t('file_open_failed'));} diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py new file mode 100644 index 00000000..e2821ed3 --- /dev/null +++ b/tests/test_issue2823_large_markdown_preview.py @@ -0,0 +1,71 @@ +"""Regression coverage for #2823 large Markdown workspace previews.""" + +from pathlib import Path + + +WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8") + + +def _open_file_block() -> str: + marker = "async function openFile(path){" + start = WORKSPACE_JS.find(marker) + assert start != -1, "openFile() not found in workspace.js" + end = WORKSPACE_JS.find("\nfunction downloadFile", start) + assert end != -1, "downloadFile() marker not found after openFile()" + return WORKSPACE_JS[start:end] + + +def _markdown_branch() -> str: + block = _open_file_block() + start = block.find("} else if(MD_EXTS.has(ext)){") + assert start != -1, "Markdown preview branch not found in openFile()" + end = block.find("} else if(HTML_EXTS.has(ext)){", start) + assert end != -1, "HTML preview branch marker not found after Markdown branch" + return block[start:end] + + +def test_large_markdown_preview_limits_are_source_controlled(): + assert "MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024" in WORKSPACE_JS + assert "MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500" in WORKSPACE_JS + assert "function shouldRenderMarkdownPreviewAsPlainText(content)" in WORKSPACE_JS + + +def test_large_markdown_fallback_sets_raw_content_before_size_gate(): + branch = _markdown_branch() + raw_pos = branch.find("_previewRawContent = data.content") + gate_pos = branch.find("shouldRenderMarkdownPreviewAsPlainText(data.content)") + fallback_pos = branch.find("showPreview('code')") + rich_pos = branch.find("showPreview('md')") + + assert raw_pos != -1, "Markdown preview must retain raw text for Edit mode" + assert gate_pos != -1, "Markdown preview must guard rich rendering by size/line count" + assert fallback_pos != -1, "Large Markdown preview must fall back to plain text" + assert rich_pos != -1, "Small Markdown preview must still use rich Markdown mode" + assert raw_pos < gate_pos < fallback_pos < rich_pos + + +def test_large_markdown_fallback_uses_code_view_without_rich_render_or_katex(): + branch = _markdown_branch() + gate_pos = branch.find("if(shouldRenderMarkdownPreviewAsPlainText(data.content)){") + fallback_end = branch.find("return;", gate_pos) + assert gate_pos != -1 and fallback_end != -1, "Large Markdown fallback block not found" + + fallback = branch[gate_pos:fallback_end] + compact = fallback.replace(" ", "") + assert "$('previewCode').textContent=data.content" in compact + assert "setStatus(" in fallback + assert "renderMd(" not in fallback + assert "renderKatexBlocks" not in fallback + + +def test_small_markdown_still_renders_and_runs_katex_after_render(): + branch = _markdown_branch() + fallback_end = branch.find("return;") + assert fallback_end != -1, "Large Markdown fallback must return before rich rendering" + + rich = branch[fallback_end:] + render_pos = rich.find("$('previewMd').innerHTML=renderMd(data.content)") + katex_pos = rich.rfind("renderKatexBlocks") + assert render_pos != -1, "Small Markdown files must still rich-render with renderMd()" + assert katex_pos != -1, "Small Markdown file previews must still trigger KaTeX rendering" + assert katex_pos > render_pos