update: PythonEditor.tsx
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
|
||||||
|
import Icon from "../../components/common/Icon";
|
||||||
|
|
||||||
|
import type { ScriptItem } from "../../services/api";
|
||||||
|
|
||||||
|
interface PythonEditorProps {
|
||||||
|
script: ScriptItem;
|
||||||
|
scriptId: string;
|
||||||
|
initialContent: string;
|
||||||
|
saving: boolean;
|
||||||
|
dirty: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
loadError?: string | null;
|
||||||
|
onChange: (scriptId: string, value: string) => void;
|
||||||
|
onSave: (scriptId: string) => void;
|
||||||
|
onEndEditing: (scriptId: string) => void;
|
||||||
|
onCloseTab: (scriptId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PythonEditor({
|
||||||
|
script,
|
||||||
|
scriptId,
|
||||||
|
initialContent,
|
||||||
|
saving,
|
||||||
|
dirty,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onEndEditing,
|
||||||
|
onCloseTab,
|
||||||
|
}: PythonEditorProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [value, setValue] = useState(initialContent);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!mod) return;
|
||||||
|
if (e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dirty && !saving) onSave(scriptId);
|
||||||
|
} else if (e.key.toLowerCase() === "w") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCloseTab(scriptId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
containerRef.current?.addEventListener("keydown", onKey);
|
||||||
|
return () => containerRef.current?.removeEventListener("keydown", onKey);
|
||||||
|
}, [dirty, saving, onSave, onCloseTab, scriptId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || saving) return;
|
||||||
|
const t = window.setTimeout(() => onSave(scriptId), 30_000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [value, dirty, saving, onSave, scriptId]);
|
||||||
|
|
||||||
|
const hasSyncedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && initialContent !== null && !hasSyncedRef.current) {
|
||||||
|
setValue(initialContent);
|
||||||
|
hasSyncedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [loading, initialContent]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="python-editor" ref={containerRef}>
|
||||||
|
<section className="editor-opening-state" aria-live="polite" style={{ display: "block" }}>
|
||||||
|
<div className="editor-opening-state__icon">
|
||||||
|
<span className="button-spinner button-spinner--blue" />
|
||||||
|
</div>
|
||||||
|
<strong>正在打开 Python 编辑器</strong>
|
||||||
|
<p>正在加载文件内容…</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="python-editor" ref={containerRef}>
|
||||||
|
<section className="editor-opening-state has-error" aria-live="polite" style={{ display: "block" }}>
|
||||||
|
<div className="editor-opening-state__icon">
|
||||||
|
<Icon name="info" size={28} />
|
||||||
|
</div>
|
||||||
|
<strong>Python 编辑器打开失败</strong>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="python-editor" ref={containerRef}>
|
||||||
|
{dirty && <div className="editor-dirty-banner">未保存修改</div>}
|
||||||
|
<div className="python-editor__status">
|
||||||
|
<span>
|
||||||
|
<i /> Workspace Python Editor
|
||||||
|
</span>
|
||||||
|
<span>{script.relative_path}</span>
|
||||||
|
</div>
|
||||||
|
<div className="python-editor__canvas">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language="python"
|
||||||
|
theme="vs"
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
setValue(v ?? "");
|
||||||
|
onChange(scriptId, v ?? "");
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: initialContent.length > 5000 },
|
||||||
|
wordWrap: "on",
|
||||||
|
fontSize: 13,
|
||||||
|
automaticLayout: true,
|
||||||
|
renderLineHighlight: "gutter",
|
||||||
|
contextmenu: false,
|
||||||
|
dragAndDrop: false,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import type {
|
|||||||
LatestVersion,
|
LatestVersion,
|
||||||
ScriptItem,
|
ScriptItem,
|
||||||
ScriptType,
|
ScriptType,
|
||||||
} from "../../services/api";
|
} from "~/services/api";
|
||||||
import { scriptIcon } from "./WorkspaceTree";
|
import { scriptIcon } from "./WorkspaceTree";
|
||||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
import { useRef, useLayoutEffect, useState, useEffect } from "react";
|
import { useRef, useLayoutEffect, useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
import { useScriptWorkspaceStore, type PythonEditorBuffer } from "./state/scriptWorkspaceStore";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { PythonEditor } from "./PythonEditor";
|
||||||
import { getScriptContent } from "../../services/api";
|
import { useAuth } from "~/context/AuthContext";
|
||||||
|
import { getScriptContent } from "~/services/api";
|
||||||
|
|
||||||
type ToastState = {
|
type ToastState = {
|
||||||
tone: "success" | "error" | "info";
|
tone: "success" | "error" | "info";
|
||||||
@@ -28,6 +29,7 @@ interface CachedSession {
|
|||||||
|
|
||||||
type ScriptWorkspaceProps = {
|
type ScriptWorkspaceProps = {
|
||||||
script: ScriptItem;
|
script: ScriptItem;
|
||||||
|
scripts: ScriptItem[];
|
||||||
sessionCache: Map<string, CachedSession>;
|
sessionCache: Map<string, CachedSession>;
|
||||||
editSession: ActiveEditSession | null;
|
editSession: ActiveEditSession | null;
|
||||||
jupyterUrl: string | null;
|
jupyterUrl: string | null;
|
||||||
@@ -43,6 +45,13 @@ type ScriptWorkspaceProps = {
|
|||||||
onNewTab: () => void;
|
onNewTab: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
onInfo: (toast: ToastState) => 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) {
|
function formatTime(value: string) {
|
||||||
@@ -104,6 +113,7 @@ function confineJupyterFrame(frame: HTMLIFrameElement): void {
|
|||||||
|
|
||||||
export function ScriptWorkspace({
|
export function ScriptWorkspace({
|
||||||
script,
|
script,
|
||||||
|
scripts,
|
||||||
sessionCache,
|
sessionCache,
|
||||||
editSession,
|
editSession,
|
||||||
jupyterUrl,
|
jupyterUrl,
|
||||||
@@ -119,13 +129,30 @@ export function ScriptWorkspace({
|
|||||||
onNewTab,
|
onNewTab,
|
||||||
onPublish,
|
onPublish,
|
||||||
onInfo,
|
onInfo,
|
||||||
|
|
||||||
|
pythonEditorBuffers,
|
||||||
|
onOpenPythonEditor,
|
||||||
|
onSetPythonEditorContent,
|
||||||
|
onSavePythonEditor,
|
||||||
|
onExitPythonEditor,
|
||||||
|
onClosePythonTab,
|
||||||
}: ScriptWorkspaceProps) {
|
}: ScriptWorkspaceProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isNotebook = script.script_type === "notebook";
|
const isNotebook = script.script_type === "notebook";
|
||||||
|
const isPython = script.script_type === "python";
|
||||||
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id)
|
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id)
|
||||||
const isEditing = editSession?.session_status === "active"
|
const isEditing = editSession?.session_status === "active"
|
||||||
&& editSession?.script_id === script.script_id;
|
&& 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;
|
||||||
|
|
||||||
// 只读模式状态
|
// 只读模式状态
|
||||||
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
|
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
|
||||||
@@ -249,7 +276,23 @@ export function ScriptWorkspace({
|
|||||||
<strong>{script.script_name}</strong>
|
<strong>{script.script_name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-toolbar__actions">
|
<div className="editor-toolbar__actions">
|
||||||
{isEditing ? (
|
{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
|
<button
|
||||||
className="end-edit-button"
|
className="end-edit-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -327,6 +370,40 @@ export function ScriptWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
|
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
|
||||||
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
|
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
|
||||||
|
{/* 多 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 */}
|
{/* 渲染所有缓存的 iframe */}
|
||||||
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
|
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
|
||||||
const isActive = scriptId === script.script_id;
|
const isActive = scriptId === script.script_id;
|
||||||
@@ -366,67 +443,67 @@ export function ScriptWorkspace({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 当前脚本没有缓存时的状态显示 */}
|
{/* 当前脚本没有缓存时的状态显示 */}
|
||||||
{!sessionCache.has(script.script_id) ? (
|
{!sessionCache.has(script.script_id) ? (
|
||||||
isNotebook ? (
|
isNotebook ? (
|
||||||
<section
|
<section
|
||||||
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{ display: 'block' }}
|
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}
|
||||||
>
|
>
|
||||||
<div className="editor-opening-state__icon">
|
{editBusy
|
||||||
{openError
|
? <span className="button-spinner button-spinner--blue" />
|
||||||
? <Icon name="info" size={28} />
|
: <Icon name="external" size={17} />}
|
||||||
: <span className="button-spinner button-spinner--blue" />}
|
{editBusy
|
||||||
</div>
|
? "正在准备编辑器…"
|
||||||
<strong>
|
: isEditing
|
||||||
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
|
? "继续编辑"
|
||||||
</strong>
|
: "打开编辑器"}
|
||||||
<p>
|
</button>
|
||||||
{openError
|
</div>
|
||||||
? 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>
|
|
||||||
) : (
|
|
||||||
<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 className="metadata-grid">
|
||||||
<div>
|
<div>
|
||||||
<span>脚本类型</span>
|
<span>脚本类型</span>
|
||||||
<strong>Python</strong>
|
<strong>Python</strong>
|
||||||
@@ -448,8 +525,7 @@ export function ScriptWorkspace({
|
|||||||
<strong>{formatTime(script.updated_at)}</strong>
|
<strong>{formatTime(script.updated_at)}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="preview-card">
|
||||||
<div className="preview-card">
|
|
||||||
<div className="preview-card__bar">
|
<div className="preview-card__bar">
|
||||||
<div>
|
<div>
|
||||||
<span className="window-dot window-dot--red" />
|
<span className="window-dot window-dot--red" />
|
||||||
@@ -465,23 +541,23 @@ export function ScriptWorkspace({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="integrity-row">
|
<div className="integrity-row">
|
||||||
<span>
|
<span>
|
||||||
<Icon name="check" size={15} />
|
<Icon name="check" size={15} />
|
||||||
</span>
|
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
|
||||||
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
</span>
|
||||||
<span>
|
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||||
稳定版本
|
<span>
|
||||||
{versionsLoading
|
稳定版本
|
||||||
? "加载中"
|
{versionsLoading
|
||||||
: latestVersion
|
? "加载中"
|
||||||
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
: latestVersion
|
||||||
: "尚未发布"}
|
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
||||||
</span>
|
: "尚未发布"}
|
||||||
</div>
|
</span>
|
||||||
</section>
|
</div>
|
||||||
)
|
</section>
|
||||||
) : null}
|
) : null):null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export default function ScriptsPage() {
|
|||||||
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
|
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
|
||||||
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
|
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
|
||||||
|
|
||||||
|
const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers);
|
||||||
|
|
||||||
// store actions
|
// store actions
|
||||||
const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword);
|
const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword);
|
||||||
const load = useScriptWorkspaceStore((s) => s.load);
|
const load = useScriptWorkspaceStore((s) => s.load);
|
||||||
@@ -42,6 +44,10 @@ export default function ScriptsPage() {
|
|||||||
const switchTab = useScriptWorkspaceStore((s) => s.switchTab);
|
const switchTab = useScriptWorkspaceStore((s) => s.switchTab);
|
||||||
const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor);
|
const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor);
|
||||||
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
|
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
|
||||||
|
const openPythonEditor = useScriptWorkspaceStore((s) => s.openPythonEditor);
|
||||||
|
const setPythonEditorContent = useScriptWorkspaceStore((s) => s.setPythonEditorContent);
|
||||||
|
const savePythonEditor = useScriptWorkspaceStore((s) => s.savePythonEditor);
|
||||||
|
const exitPythonEditor = useScriptWorkspaceStore((s) => s.exitPythonEditor);
|
||||||
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
|
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
|
||||||
const createScript = useScriptWorkspaceStore((s) => s.createScript);
|
const createScript = useScriptWorkspaceStore((s) => s.createScript);
|
||||||
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
|
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
|
||||||
@@ -166,6 +172,20 @@ export default function ScriptsPage() {
|
|||||||
};
|
};
|
||||||
}, [contextMenu, closeContextMenu]);
|
}, [contextMenu, closeContextMenu]);
|
||||||
|
|
||||||
|
// python editor handlers with scriptId forwarding
|
||||||
|
const handleSetPythonEditorContent = (scriptId: string, value: string) => {
|
||||||
|
setPythonEditorContent(scriptId, value);
|
||||||
|
};
|
||||||
|
const handleSavePythonEditor = (scriptId: string) => {
|
||||||
|
void savePythonEditor(scriptId);
|
||||||
|
};
|
||||||
|
const handleExitPythonEditor = (scriptId: string) => {
|
||||||
|
exitPythonEditor(scriptId);
|
||||||
|
};
|
||||||
|
const handleClosePythonTab = (scriptId: string) => {
|
||||||
|
void closeTab(scriptId);
|
||||||
|
};
|
||||||
|
|
||||||
// 5) handlers
|
// 5) handlers
|
||||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files ?? []);
|
const files = Array.from(event.target.files ?? []);
|
||||||
@@ -242,12 +262,31 @@ export default function ScriptsPage() {
|
|||||||
scriptType: s?.script_type ?? "notebook",
|
scriptType: s?.script_type ?? "notebook",
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
onOpenEditor={() => void openScriptEditor(selected)}
|
onOpenEditor={() => {
|
||||||
onEndEditing={() => void endEditing()}
|
if (selected.script_type === "python") {
|
||||||
|
void openPythonEditor(selected);
|
||||||
|
} else {
|
||||||
|
void openScriptEditor(selected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndEditing={() => {
|
||||||
|
if (selected && pythonEditorBuffers[selected.script_id]) {
|
||||||
|
exitPythonEditor(selected.script_id);
|
||||||
|
} else {
|
||||||
|
void endEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClose={(scriptId, event) => void closeTab(scriptId, event)}
|
onClose={(scriptId, event) => void closeTab(scriptId, event)}
|
||||||
onSwitchTab={switchTab}
|
onSwitchTab={switchTab}
|
||||||
onNewTab={() => openCreateDialog("")}
|
onNewTab={() => openCreateDialog("")}
|
||||||
onPublish={() => openPublishDialog(selected)}
|
onPublish={() => openPublishDialog(selected)}
|
||||||
|
scripts={scripts}
|
||||||
|
pythonEditorBuffers={pythonEditorBuffers}
|
||||||
|
onOpenPythonEditor={() => void openPythonEditor(selected)}
|
||||||
|
onSetPythonEditorContent={handleSetPythonEditorContent}
|
||||||
|
onSavePythonEditor={handleSavePythonEditor}
|
||||||
|
onExitPythonEditor={handleExitPythonEditor}
|
||||||
|
onClosePythonTab={handleClosePythonTab}
|
||||||
onInfo={(t) => pushToast(t)}
|
onInfo={(t) => pushToast(t)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ type CachedSession = {
|
|||||||
lastActiveTime: number;
|
lastActiveTime: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PythonEditorBuffer = {
|
||||||
|
initialContent: string | null;
|
||||||
|
content: string | null;
|
||||||
|
dirty: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
initial: boolean;
|
||||||
|
loadError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// 模块级可变 holder(非响应式,避免 React 重渲)
|
// 模块级可变 holder(非响应式,避免 React 重渲)
|
||||||
const sessionCache = new Map<string, CachedSession>();
|
const sessionCache = new Map<string, CachedSession>();
|
||||||
let _selectedId: string | null = null;
|
let _selectedId: string | null = null;
|
||||||
@@ -29,6 +38,7 @@ let _editorOpenRequest = 0;
|
|||||||
let _api: WorkspaceBoundApi | null = null;
|
let _api: WorkspaceBoundApi | null = null;
|
||||||
let _previewController: AbortController | null = null;
|
let _previewController: AbortController | null = null;
|
||||||
let _previewRequest = 0;
|
let _previewRequest = 0;
|
||||||
|
let _pythonEditorOpeningIds = new Set<string>();
|
||||||
|
|
||||||
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
|
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
|
||||||
_api = api;
|
_api = api;
|
||||||
@@ -68,6 +78,8 @@ type State = {
|
|||||||
previewLoading: boolean;
|
previewLoading: boolean;
|
||||||
previewError: string | null;
|
previewError: string | null;
|
||||||
|
|
||||||
|
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
setApiOnline: (online: boolean) => void;
|
setApiOnline: (online: boolean) => void;
|
||||||
setKeyword: (keyword: string) => void;
|
setKeyword: (keyword: string) => void;
|
||||||
@@ -79,6 +91,11 @@ type State = {
|
|||||||
switchTab: (id: string) => void;
|
switchTab: (id: string) => void;
|
||||||
openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
|
openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
|
||||||
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
|
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
|
||||||
|
openPythonEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
|
||||||
|
setPythonEditorContent: (scriptId: string, value: string) => void;
|
||||||
|
savePythonEditor: (scriptId: string) => Promise<void>;
|
||||||
|
exitPythonEditor: (scriptId: string) => void;
|
||||||
|
exitAllPythonEditors: () => void;
|
||||||
loadLatestVersion: (scriptId: string) => Promise<void>;
|
loadLatestVersion: (scriptId: string) => Promise<void>;
|
||||||
loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
|
loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
|
||||||
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
|
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
|
||||||
@@ -148,6 +165,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
previewLoading: false,
|
previewLoading: false,
|
||||||
previewError: null,
|
previewError: null,
|
||||||
|
|
||||||
|
pythonEditorBuffers: {},
|
||||||
|
|
||||||
setApiOnline: (online) => set({ apiOnline: online }),
|
setApiOnline: (online) => set({ apiOnline: online }),
|
||||||
setKeyword: (keyword) => set({ keyword }),
|
setKeyword: (keyword) => set({ keyword }),
|
||||||
|
|
||||||
@@ -161,6 +180,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
_editSession = null;
|
_editSession = null;
|
||||||
editSessionHandle.current = null;
|
editSessionHandle.current = null;
|
||||||
sessionCache.clear();
|
sessionCache.clear();
|
||||||
|
_pythonEditorOpeningIds.clear();
|
||||||
set({
|
set({
|
||||||
scripts: [],
|
scripts: [],
|
||||||
directories: [],
|
directories: [],
|
||||||
@@ -176,6 +196,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
previewCodeSize: null,
|
previewCodeSize: null,
|
||||||
previewLoading: false,
|
previewLoading: false,
|
||||||
previewError: null,
|
previewError: null,
|
||||||
|
pythonEditorBuffers: {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -236,6 +257,15 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
|
|
||||||
closeTab: async (id, event) => {
|
closeTab: async (id, event) => {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
|
const buffer = get().pythonEditorBuffers[id];
|
||||||
|
if (buffer?.dirty && !buffer.saving) {
|
||||||
|
const name = get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本";
|
||||||
|
const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
if (buffer) {
|
||||||
|
get().exitPythonEditor(id);
|
||||||
|
}
|
||||||
if (_editSession?.script_id === id) {
|
if (_editSession?.script_id === id) {
|
||||||
await get().endEditing(false, false);
|
await get().endEditing(false, false);
|
||||||
}
|
}
|
||||||
@@ -255,6 +285,13 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
switchTab: (id) => {
|
switchTab: (id) => {
|
||||||
|
const current = get().selectedId;
|
||||||
|
if (current && current !== id) {
|
||||||
|
const curBuffer = get().pythonEditorBuffers[current];
|
||||||
|
if (curBuffer?.dirty && !curBuffer.saving) {
|
||||||
|
void get().savePythonEditor(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (_selectedId !== id) {
|
if (_selectedId !== id) {
|
||||||
_editorOpenRequest += 1;
|
_editorOpenRequest += 1;
|
||||||
set({ editorOpenError: null });
|
set({ editorOpenError: null });
|
||||||
@@ -268,6 +305,140 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openPythonEditor: async (script, showToast = true) => {
|
||||||
|
if (!script) return;
|
||||||
|
if (_pythonEditorOpeningIds.has(script.script_id)) return;
|
||||||
|
_pythonEditorOpeningIds.add(script.script_id);
|
||||||
|
try {
|
||||||
|
const workspaceId = script.workspace_id;
|
||||||
|
const url =
|
||||||
|
`/jupyter/${workspaceId}/api/contents/${script.jupyter_path}` +
|
||||||
|
`?type=file&content=1&hash=1&format=text`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`加载文件失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const content = typeof data.content === "string" ? data.content : "";
|
||||||
|
set((state) => ({
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[script.script_id]: {
|
||||||
|
initialContent: content,
|
||||||
|
content,
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
|
initial: true,
|
||||||
|
loadError: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "打开 Python 编辑器失败";
|
||||||
|
set((state) => ({
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[script.script_id]: {
|
||||||
|
initialContent: null,
|
||||||
|
content: null,
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
|
initial: false,
|
||||||
|
loadError: message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
pushToast("error", message);
|
||||||
|
} finally {
|
||||||
|
_pythonEditorOpeningIds.delete(script.script_id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPythonEditorContent: (scriptId, value) => {
|
||||||
|
set((state) => {
|
||||||
|
const buffer = state.pythonEditorBuffers[scriptId];
|
||||||
|
if (!buffer) return state;
|
||||||
|
return {
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[scriptId]: {
|
||||||
|
...buffer,
|
||||||
|
content: value,
|
||||||
|
dirty: value !== buffer.initialContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
savePythonEditor: async (scriptId) => {
|
||||||
|
const buffer = get().pythonEditorBuffers[scriptId];
|
||||||
|
if (!buffer || buffer.content === null || !buffer.dirty || buffer.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = get().scripts.find((s) => s.script_id === scriptId);
|
||||||
|
if (!script) return;
|
||||||
|
const savedContent = buffer.content;
|
||||||
|
set((state) => ({
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[scriptId]: { ...buffer, saving: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const api = requireApi();
|
||||||
|
await api.updateScript(scriptId, { content: savedContent });
|
||||||
|
set((state) => {
|
||||||
|
const current = state.pythonEditorBuffers[scriptId];
|
||||||
|
if (!current) return state;
|
||||||
|
return {
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[scriptId]: {
|
||||||
|
...current,
|
||||||
|
initialContent: savedContent,
|
||||||
|
dirty: current.content !== savedContent,
|
||||||
|
saving: false,
|
||||||
|
initial: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
pushToast("success", "已保存");
|
||||||
|
} catch (error) {
|
||||||
|
set((state) => {
|
||||||
|
const current = state.pythonEditorBuffers[scriptId];
|
||||||
|
if (!current) return state;
|
||||||
|
return {
|
||||||
|
pythonEditorBuffers: {
|
||||||
|
...state.pythonEditorBuffers,
|
||||||
|
[scriptId]: { ...current, saving: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
pushToast(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error.message : "保存失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exitPythonEditor: (scriptId) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = { ...state.pythonEditorBuffers };
|
||||||
|
delete next[scriptId];
|
||||||
|
return { pythonEditorBuffers: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exitAllPythonEditors: () => {
|
||||||
|
_pythonEditorOpeningIds.clear();
|
||||||
|
set({ pythonEditorBuffers: {} });
|
||||||
|
},
|
||||||
|
|
||||||
openScriptEditor: async (script, showToast = true) => {
|
openScriptEditor: async (script, showToast = true) => {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
if (_editorOpening) return;
|
if (_editorOpening) return;
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-canvas {
|
.editor-canvas {
|
||||||
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -1990,6 +1991,81 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Save button — green primary tone for python editor */
|
||||||
|
.editor-toolbar__actions .editor-save-button {
|
||||||
|
color: #1f6d3a;
|
||||||
|
background: #f1faf3;
|
||||||
|
border-color: #b8dec1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.editor-toolbar__actions .editor-save-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.editor-toolbar__actions .editor-save-button.is-saving {
|
||||||
|
color: #1f6d3a;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.editor-toolbar__actions .editor-save-button .button-spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dirty banner — top of editor canvas */
|
||||||
|
.editor-dirty-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-bottom: 1px solid #f0d8a8;
|
||||||
|
color: #9b6a18;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.editor-dirty-banner::before {
|
||||||
|
content: "●";
|
||||||
|
color: #d49b00;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Python editor wrapper */
|
||||||
|
.python-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.python-editor__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-bottom: 1px solid #e6e8ec;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.python-editor__canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Python editor mount — multiple instances, only active visible */
|
||||||
|
.python-editor-mount {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 只读编辑器样式 ============ */
|
/* ============ 只读编辑器样式 ============ */
|
||||||
.readonly-editor-container {
|
.readonly-editor-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user