Files
model-platform/frontend/app/features/platform/ScriptWorkspace.tsx
T
tao.chen b600c6810b fix: P0-5 — upload-status rollback, streaming copy (LOCAL only), user re-verify, honest lock
B1: `_mark_upload_failed_and_raise` now commits on a separate session
  - Helper takes `request + upload_id`, opens a fresh session from
    `request.app.state.session_factory` and commits there before raising.
  - Closes the named-lock connection-pool leak Codex flagged: the old
    "commit-on-the-same-session" implementation could return the
    GET_LOCK connection to the pool before the enclosing
    `finally: release_named_lock` ran, leaking `mp:<hash>` for up to
    `pool_recycle` and re-opening the same-key upload race.
  - Same helper now used by `create_server_object_payload`'s put-failure
    branch — two failure paths have identical semantics.

B2: streaming copy for soft-delete + restore (`get_stream() + put()`)
  - LOCAL backend: zero-copy (aiofiles stream write). OOM fixed.
  - S3 backend: still OOMs on multi-GB objects — `put()` materializes
    the async iter via `b"".join(chunks)`. Multipart `put` is a
    follow-up; do NOT claim "OOM fixed on production" since production
    defaults to S3.

C1: worker re-verifies `Users.status='active' AND is_deleted=0`
  - `_assert_user_active` called from `_execution_context` after
    resolving `triggered_by`; skips `SYSTEM_CRON_USER_ID`.
  - `USER_DISABLED` error_code goes into the `NODE_FINISHED_EVENT`
    outbox payload — `schedule_node_runs` has no `error_code` column,
    the row only carries the `message` text. Docstrings corrected to
    say so explicitly (previous docstring falsely promised row-level
    observability).

F1: honest browser-local file lock
  - `api.ts` `acquireFileLock/heartbeatFileLock/releaseFileLock/
    releaseFileLockOnUnload` are now no-ops with comments stating they
    never call the network.
  - `scriptWorkspaceStore` dropped `tickHeartbeats`; `tickCleanup`
    simplified to just clear cache.
  - `useEditSessionLifecycle` dropped its 15s heartbeat `setInterval`.
  - `ScriptWorkspace.tsx` renders `.local-lock-banner` info bar when
    `isEditing`. Two tabs may still silently last-write — banner is the
    only guard (acceptable disclosure-only tradeoff).

Dead code: deleted the duplicate `upload_bytes_to_session` in
`backend/src/backend/storage_api.py`. The `services.storage` import
is now the only source of the function; `create_upload_record`'s
docstring updated to point at `backend.resources`.
2026-08-20 12:07:52 +08:00

791 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Icon from "../../components/common/Icon";
import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
ScriptType,
} from "~/services/api";
import { scriptIcon } from "./WorkspaceTree";
import type { MouseEvent as ReactMouseEvent } from "react";
import Editor from "@monaco-editor/react";
import { useRef, useLayoutEffect, useState, useEffect } from "react";
import {type PythonEditorBuffer, useScriptWorkspaceStore} from "./state/scriptWorkspaceStore";
import { useAuth } from "../../context/AuthContext";
import { getScriptContent } from "../../services/api";
import {PythonEditor} from "~/features/platform/PythonEditor";
// 模块级变量存储刷新版本号,用于检测只读内容刷新
let _lastRefreshVersion = 0;
type ToastState = {
tone: "success" | "error" | "info";
message: string;
};
// 缓存的会话类型(多 iframe 共存方案)
interface CachedSession {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
}
type ScriptWorkspaceProps = {
script: ScriptItem;
scripts: ScriptItem[];
sessionCache: Map<string, CachedSession>;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
openError: string | null;
latestVersion: LatestVersion | null;
versionsLoading: boolean;
openTabs: Array<{ scriptId: string; scriptName: string; scriptType: ScriptType }>;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: (scriptId: string, event?: ReactMouseEvent) => void;
onSwitchTab: (scriptId: string) => void;
onNewTab: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
onOpenPythonEditor: () => void;
onSetPythonEditorContent: (scriptId: string, v: string) => void;
onSavePythonEditor: (scriptId: string) => void;
onExitPythonEditor: (scriptId: string) => void;
onClosePythonTab: (scriptId: string) => void;
};
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
return `${(value / 1024).toFixed(1)} KB`;
}
function shortHash(value: string) {
return value ? `${value.slice(0, 8)}${value.slice(-6)}` : "—";
}
// Notebook 单元格类型
type NotebookCell = {
cell_type: "code" | "markdown" | "raw";
source: string[] | string;
execution_count?: number | null;
outputs?: Array<{
name?: "stdout" | "stderr" | string;
output_type: "stream" | "execute_result" | "display_data" | "error";
text?: string[] | string;
data?: Record<string, unknown>;
}>;
};
// Notebook Viewer 组件
function NotebookViewer({ content }: { content: object }) {
const notebook = content as {
cells?: NotebookCell[];
metadata?: Record<string, unknown>;
nbformat?: number;
nbformat_minor?: number;
};
const cells = notebook.cells ?? [];
return (
<div className="notebook-read-only">
{cells.map((cell, index) => {
const sourceText = Array.isArray(cell.source)
? cell.source.join("")
: (cell.source ?? "");
if (cell.cell_type === "markdown") {
return (
<div key={index} className="notebook-cell notebook-cell--markdown">
<div className="notebook-cell__content">
{renderMarkdown(sourceText)}
</div>
</div>
);
}
if (cell.cell_type === "code") {
const hasSource = sourceText.trim().length > 0;
return (
<div key={index} className="notebook-cell notebook-cell--code">
<div className="notebook-cell__prompt">
In [{cell.execution_count ?? " "}]:
</div>
<div className="notebook-cell__content">
<div className={`notebook-cell__input${!hasSource ? ' notebook-cell__input--empty' : ''}`}>
<pre><code>{sourceText}</code></pre>
</div>
{cell.outputs && cell.outputs.length > 0 && (
<div className="notebook-cell__outputs">
{cell.outputs.map((output, outputIndex) => {
// 处理 stream 类型的输出
if (output.output_type === "stream" && output.text) {
const text = Array.isArray(output.text)
? output.text.join("")
: output.text;
return (
<div key={outputIndex} className={`notebook-output notebook-output--${output.name}`}>
<pre>{text}</pre>
</div>
);
}
// 处理 execute_result / display_data 类型
if (output.output_type === "execute_result" || output.output_type === "display_data") {
const text = output.data?.["text/plain"];
if (text) {
const textStr = Array.isArray(text) ? text.join("") : String(text);
return (
<div key={outputIndex} className="notebook-output">
<pre>{textStr}</pre>
</div>
);
}
}
return null;
})}
</div>
)}
</div>
</div>
);
}
return null;
})}
</div>
);
}
// 简单的 markdown 渲染函数
function renderMarkdown(text: string) {
const lines = text.split("\n");
return lines.map((line, i) => {
if (line.startsWith("# ")) {
return <h1 key={i}>{line.slice(2)}</h1>;
}
if (line.startsWith("## ")) {
return <h2 key={i}>{line.slice(3)}</h2>;
}
if (line.startsWith("### ")) {
return <h3 key={i}>{line.slice(4)}</h3>;
}
if (line.startsWith("- ") || line.startsWith("* ")) {
return <li key={i}>{line.slice(2)}</li>;
}
if (line.match(/^\d+\. /)) {
return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
}
if (line.trim() === "") {
return <br key={i} />;
}
return <p key={i}>{line}</p>;
});
}
function confineJupyterFrame(frame: HTMLIFrameElement, readOnly: boolean = false): void {
try {
const document = frame.contentDocument;
if (!document?.documentElement) return;
const keepInside = (): void => {
// 隐藏 "Open in..." 按钮
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
(element) => {
const label = `${element.getAttribute("aria-label") ?? ""} ${
element.getAttribute("title") ?? ""
} ${element.textContent ?? ""}`.trim();
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
element.style.setProperty("display", "none", "important");
}
// 只读模式下隐藏保存/编辑相关的按钮
if (readOnly) {
if (/\bsave\b|\bedit\b|\brun\b/i.test(label)) {
element.style.setProperty("display", "none", "important");
}
}
},
);
// 阻止新窗口打开链接
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
if (["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
});
};
keepInside();
new MutationObserver(keepInside).observe(document.documentElement, {
childList: true,
subtree: true,
});
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a") as HTMLAnchorElement | null;
if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
}, true);
} catch {
// The iframe remains sandboxed even if its document is not yet accessible.
}
}
export function ScriptWorkspace({
script,
scripts,
sessionCache,
editSession,
jupyterUrl,
editBusy,
openError,
latestVersion,
versionsLoading,
openTabs,
onOpenEditor,
onEndEditing,
onClose,
onSwitchTab,
onNewTab,
onPublish,
onInfo,
pythonEditorBuffers,
onOpenPythonEditor,
onSetPythonEditorContent,
onSavePythonEditor,
onExitPythonEditor,
onClosePythonTab,
}: ScriptWorkspaceProps) {
const { user } = useAuth();
const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook";
const isPython = script.script_type === "python";
// 订阅 store 的刷新版本号,用于触发只读内容刷新
const readOnlyRefreshVersion = useScriptWorkspaceStore((s) => s.readOnlyRefreshVersion);
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id
const isEditing = editSession?.session_status === "active"
&& editSession?.script_id === script.script_id;
const activePythonBuf = isPython ? pythonEditorBuffers[script.script_id] : null;
const isPythonEditing = isPython && !!activePythonBuf;
const showSaveButton = isPythonEditing && activePythonBuf &&
(activePythonBuf.dirty || activePythonBuf.saving || activePythonBuf.initial);
const saveDisabled = !activePythonBuf?.dirty || activePythonBuf?.saving;
const pythonTabBuffers = openTabs.filter(
(t) => t.scriptType === "python" && pythonEditorBuffers[t.scriptId],
);
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
// 只读模式状态(用于 Python 文件和 notebook 的 JSON 内容)
const [readOnlyContent, setReadOnlyContent] = useState<string | object | null>(null);
const [readOnlyLoading, setReadOnlyLoading] = useState(false);
const [readOnlyError, setReadOnlyError] = useState<string | null>(null);
// 判断是否启用只读模式:文件已锁定 + 非所有者 + 非管理员
const isReadOnlyMode = script.is_locked
&& user?.user_id !== script.owner_user_id
&& user?.role_code !== "admin";
const scroll = (direction: "left" | "right") => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const scrollAmount = 200;
tabbar.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
};
useLayoutEffect(() => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const activeTab = tabbar.querySelector(".editor-tab--active") as HTMLElement | null;
if (activeTab) {
const tabbarRect = tabbar.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.right > tabbarRect.right || tabRect.left < tabbarRect.left) {
activeTab.scrollIntoView({ behavior: "smooth", inline: "center" });
}
}
}, [script.script_id]);
// 加载只读内容(当处于只读模式时)
useEffect(() => {
if (!isReadOnlyMode) {
setReadOnlyContent(null);
setReadOnlyLoading(false);
setReadOnlyError(null);
return;
}
setReadOnlyLoading(true);
setReadOnlyError(null);
getScriptContent(script.workspace_id, script.script_id)
.then((data) => {
setReadOnlyContent(data.content);
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
})
.finally(() => {
setReadOnlyLoading(false);
});
}, [isReadOnlyMode, script.script_id, script.workspace_id]);
// 监听只读内容刷新
useEffect(() => {
if (readOnlyRefreshVersion > _lastRefreshVersion && isReadOnlyMode) {
_lastRefreshVersion = readOnlyRefreshVersion;
// 重新加载只读内容
setReadOnlyLoading(true);
setReadOnlyError(null);
getScriptContent(script.workspace_id, script.script_id)
.then((data) => {
setReadOnlyContent(data.content);
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
})
.finally(() => {
setReadOnlyLoading(false);
});
}
}, [script.script_id, script.workspace_id, isReadOnlyMode, readOnlyRefreshVersion]);
return (
<>
<div className="tabbar">
<button
className="tabbar-scroll-btn tabbar-scroll-btn--left"
type="button"
aria-label="向左滚动"
onClick={() => scroll("left")}
>
<Icon name="chevron" size={16} />
</button>
<div className="tabbar-scroll-content" ref={tabbarRef}>
{openTabs.map((tab) => (
<div
key={tab.scriptId}
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`}
onClick={() => onSwitchTab(tab.scriptId)}
>
<span className={`file-icon file-icon--${tab.scriptType}`}>
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} />
</span>
<span>{tab.scriptName}</span>
<button
type="button"
aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)}
>
<Icon name="close" size={14} />
</button>
</div>
))}
<button
className="new-tab"
type="button"
onClick={onNewTab}
>
<Icon name="plus" size={17} />
</button>
</div>
<button
className="tabbar-scroll-btn tabbar-scroll-btn--right"
type="button"
aria-label="向右滚动"
onClick={() => scroll("right")}
>
<Icon name="chevron" size={16} />
</button>
</div>
<div className="editor-toolbar">
<div className="editor-toolbar__path">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={17} />
</span>
<span>工作副本</span>
<Icon name="chevron" size={13} />
<strong>{script.script_name}</strong>
</div>
{!isReadOnlyMode && (
<div className="editor-toolbar__actions">
{isPythonEditing ? (
<>
{showSaveButton && (
<button
type="button"
className={`editor-save-button${activePythonBuf?.saving ? " is-saving" : ""}`}
disabled={saveDisabled}
onClick={() => onSavePythonEditor(script.script_id)}
>
{activePythonBuf?.saving
? <><span className="button-spinner button-spinner--blue" /> 保存中</>
: "保存"}
</button>
)}
<button type="button" className="end-edit-button" onClick={() => onExitPythonEditor(script.script_id)}>结束编辑</button>
</>
) : isEditing ? (
<button
className="end-edit-button"
type="button"
disabled={editBusy}
onClick={onEndEditing}
>
{editBusy ? "正在释放…" : "结束编辑"}
</button>
) : (
<button
type="button"
onClick={() => onInfo({
tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
})}
>
保存说明
</button>
)}
<button
className="release-button"
type="button"
onClick={onPublish}
>
发布稳定版
</button>
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
<span />
{
latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"
}
</span>
</div>
)}
</div>
{/* 本地编辑锁提示——只在非 Python 编辑时显示,
因为 Python 编辑走 pythonEditorBuffers,不走文件锁。
这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */}
{isEditing && (
<div className="local-lock-banner">
<Icon name="info" size={14} />
<span>
本地编辑锁——关闭标签页、刷新页面或换浏览器后失效,不会阻止他人同时编辑。
</span>
</div>
)}
{/* 编辑器画布区域 - 始终渲染,保证 iframe 不重新加载 */}
<div className="editor-canvas-wrapper" style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
{/* 只读模式内容 - 用 CSS 控制显示/隐藏 */}
<div style={{ display: isReadOnlyMode ? 'block' : 'none', height: '100%' }}>
<div className="readonly-editor-container">
<div className="readonly-editor-banner">
<Icon name="lock" size={16} />
<span>此文件已被锁定,您当前处于只读模式</span>
</div>
<div className="readonly-editor-content">
{readOnlyLoading ? (
<div className="readonly-editor-loading">
<span className="button-spinner button-spinner--blue" />
<span>加载内容中...</span>
</div>
) : readOnlyError ? (
<div className="readonly-editor-error">
<Icon name="info" size={28} />
<strong>加载失败</strong>
<p>{readOnlyError}</p>
</div>
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
<NotebookViewer content={readOnlyContent} />
) : (
<Editor
height="100%"
language="python"
value={typeof readOnlyContent === 'string' ? readOnlyContent : ''}
theme="vs"
options={{
readOnly: true,
domReadOnly: true,
minimap: { enabled: true },
lineNumbers: "on",
folding: true,
wordWrap: "on",
contextmenu: false,
automaticLayout: true,
}}
/>
)}
</div>
</div>
</div>
{/* 正常编辑模式 - 始终渲染,用 CSS 控制显示/隐藏 */}
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={{ display: isReadOnlyMode ? 'none' : 'block', position: 'relative', minHeight: '0', height: '100%' }}>
{/* 多 PythonEditor 实例:每个有 buffer 的 python tab 都挂载,仅 active 可见 */}
{pythonTabBuffers.map((tab) => {
const tabScript = scripts.find((s) => s.script_id === tab.scriptId);
if (!tabScript) return null;
const buf = pythonEditorBuffers[tab.scriptId];
const isActive = tab.scriptId === script.script_id;
return (
<section
key={tab.scriptId}
className="python-editor-mount"
style={{
position: isActive ? "relative" : "absolute",
visibility: isActive ? "visible" : "hidden",
pointerEvents: isActive ? "auto" : "none",
width: "100%",
height: "100%",
}}
>
<PythonEditor
scriptId={tab.scriptId}
script={tabScript}
initialContent={buf.initialContent ?? ""}
loading={buf.initialContent === null && !buf.loadError}
loadError={buf.loadError}
saving={buf.saving}
dirty={buf.dirty}
onChange={onSetPythonEditorContent}
onSave={onSavePythonEditor}
onEndEditing={onExitPythonEditor}
onCloseTab={onClosePythonTab}
/>
</section>
);
})}
{/* 渲染所有缓存的 iframe */}
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
const isActive = scriptId === script.script_id;
return (
<section
key={scriptId}
className="embedded-jupyter"
style={{
position: isActive ? 'relative' : 'absolute',
visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none',
width: '100%',
height: '100%',
overflow: 'hidden'
}}
>
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={cached.session.runtime_id}>
Runtime {cached.session.runtime_id.slice(-8)}
</code>
</div>
<iframe
src={cached.jupyterUrl}
title={`${scriptId} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
);
})}
{/* 当前脚本没有缓存时的状态显示 */}
{!sessionCache.has(script.script_id) ? (
isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
style={{ display: 'block' }}
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : isPython && !activePythonBuf ? (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="external" size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="metadata-grid">
<div>
<span>脚本类型</span>
<strong>Python</strong>
</div>
<div>
<span>可见范围</span>
<strong>
{script.visibility === "workspace"
? "Workspace"
: script.visibility === "public" ? "公开" : "私有"}
</strong>
</div>
<div>
<span>文件大小</span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div>
<div>
<span>最近更新</span>
<strong>{formatTime(script.updated_at)}</strong>
</div>
</div>
<div className="preview-card">
<div className="preview-card__bar">
<div>
<span className="window-dot window-dot--red" />
<span className="window-dot window-dot--yellow" />
<span className="window-dot window-dot--green" />
</div>
<span>Python 预览</span>
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
</div>
<PythonPreview
workspaceId={script.workspace_id}
filePath={script.jupyter_path}
/>
</div>
<div className="integrity-row">
<span>
<Icon name="check" size={15} />
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
</span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span>
稳定版本&nbsp;
{versionsLoading
? "加载中"
: latestVersion
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
: "尚未发布"}
</span>
</div>
</section>
) : null
) : null}
</div>
</div>
</>
);
}
interface PythonPreviewProps {
workspaceId: string;
filePath: string;
}
export function PythonPreview({
workspaceId,
filePath,
}: PythonPreviewProps) {
const previewKey = `${workspaceId}::${filePath}`;
const storeKey = useScriptWorkspaceStore((s) => s.previewKey);
const previewCode = useScriptWorkspaceStore((s) => s.previewCode);
const previewCodeSize = useScriptWorkspaceStore((s) => s.previewCodeSize);
const previewLoading = useScriptWorkspaceStore((s) => s.previewLoading);
const previewError = useScriptWorkspaceStore((s) => s.previewError);
const loadPreview = useScriptWorkspaceStore((s) => s.loadPreview);
useLayoutEffect(() => {
void loadPreview(workspaceId, filePath);
}, [previewKey]);
if (storeKey !== previewKey) {
return <div>Loading...</div>;
}
if (previewLoading) {
return <div>Loading...</div>;
}
if (previewError) {
return <div>Failed to load: {previewError}</div>;
}
return (
<Editor
height="550px"
language="python"
value={previewCode ?? ""}
theme="vs"
options={{
readOnly: true,
domReadOnly: true,
minimap: {
enabled: previewCodeSize !== null && previewCodeSize > 1000,
},
lineNumbers: "off",
folding: true,
wordWrap: "on",
contextmenu: false,
dragAndDrop: false,
automaticLayout: true,
renderLineHighlight: "none",
}}
/>
);
}