import { type FormEvent, useEffect, useMemo, useRef } from "react"; import { useAuth } from "../../context/AuthContext"; import { CreateFolderModal } from "./CreateFolderModal"; import { CreateScriptModal } from "./CreateScriptModal"; import { DataResourceUploadModal } from "./DataResourceUploadModal"; import { WelcomePanel } from "./WelcomePanel"; import { PublishModal } from "./PublishModal"; import { ScriptExplorer } from "./ScriptExplorer"; import { TreeContextMenu } from "./TreeContextMenu"; import { VersionReceiptModal } from "./VersionReceiptModal"; import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; import { useUiStore } from "./state/uiStore"; import { toast } from "sonner"; import { ScriptWorkspace } from "./ScriptWorkspace"; import { copyToClipboard } from "../../lib/clipboard"; export default function ScriptsPage() { const { currentWorkspace, user } = useAuth(); const uploadInputRef = useRef(null); // store state const scripts = useScriptWorkspaceStore((s) => s.scripts); const directories = useScriptWorkspaceStore((s) => s.directories); const selectedId = useScriptWorkspaceStore((s) => s.selectedId); const openTabIds = useScriptWorkspaceStore((s) => s.openTabIds); const keyword = useScriptWorkspaceStore((s) => s.keyword); const loading = useScriptWorkspaceStore((s) => s.loading); const refreshing = useScriptWorkspaceStore((s) => s.refreshing); const editSession = useScriptWorkspaceStore((s) => s.editSession); const embeddedJupyterUrl = useScriptWorkspaceStore((s) => s.embeddedJupyterUrl); const editBusy = useScriptWorkspaceStore((s) => s.editBusy); const editorOpenError = useScriptWorkspaceStore((s) => s.editorOpenError); const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion); const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading); const dataResources = useScriptWorkspaceStore((s) => s.dataResources); const loadDataResources = useScriptWorkspaceStore((s) => s.loadDataResources); const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers); // store actions const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword); const load = useScriptWorkspaceStore((s) => s.load); const reset = useScriptWorkspaceStore((s) => s.reset); const selectScript = useScriptWorkspaceStore((s) => s.selectScript); const openTab = useScriptWorkspaceStore((s) => s.openTab); const closeTab = useScriptWorkspaceStore((s) => s.closeTab); const switchTab = useScriptWorkspaceStore((s) => s.switchTab); const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor); 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 createScript = useScriptWorkspaceStore((s) => s.createScript); const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts); const uploadDataResource = useScriptWorkspaceStore((s) => s.uploadDataResource); const createFolder = useScriptWorkspaceStore((s) => s.createFolder); const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript); const deleteDataResource = useScriptWorkspaceStore((s) => s.deleteDataResource); const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory); const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock); const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog); const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish); const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent); // ui store const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => { if (notice.tone === "error") toast.error(notice.message); else if (notice.tone === "info") toast.info(notice.message); else toast.success(notice.message); }; const createDialog = useUiStore((s) => s.createDialog); const setCreateForm = useUiStore((s) => s.setCreateForm); const closeCreateDialog = useUiStore((s) => s.closeCreateDialog); const folderDialog = useUiStore((s) => s.folderDialog); const setFolderName = useUiStore((s) => s.setFolderName); const closeFolderDialog = useUiStore((s) => s.closeFolderDialog); const contextMenu = useUiStore((s) => s.contextMenu); const closeContextMenu = useUiStore((s) => s.closeContextMenu); const showContextMenu = useUiStore((s) => s.showContextMenu); const upload = useUiStore((s) => s.upload); const openCreateDialog = useUiStore((s) => s.openCreateDialog); const openFolderDialog = useUiStore((s) => s.openFolderDialog); const chooseUpload = useUiStore((s) => s.chooseUpload); const dataResourceDialog = useUiStore((s) => s.dataResourceDialog); const openDataResourceDialog = useUiStore((s) => s.openDataResourceDialog); const closeDataResourceDialog = useUiStore((s) => s.closeDataResourceDialog); const setDataResourceName = useUiStore((s) => s.setDataResourceName); const setDataResourceVisibility = useUiStore((s) => s.setDataResourceVisibility); const setDataResourceDescription = useUiStore((s) => s.setDataResourceDescription); const setDataResourceTargetPath = useUiStore((s) => s.setDataResourceTargetPath); const publish = useUiStore((s) => s.publish); const setReleaseNote = useUiStore((s) => s.setReleaseNote); const setPublishVisibility = useUiStore((s) => s.setPublishVisibility); const closePublishDialog = useUiStore((s) => s.closePublishDialog); const clearPublishedVersion = useUiStore((s) => s.clearPublishedVersion); const workspaceId = currentWorkspace?.workspace_id; // 1) 初始加载 + workspace 切换时重置 useEffect(() => { void load(); }, [load]); useEffect(() => { void loadDataResources(); }, [loadDataResources, workspaceId]); useEffect(() => { reset(); // 刷新前递增版本号,触发已打开标签页的只读内容刷新 refreshReadOnlyContent(); void load(); }, [reset, load, workspaceId, refreshReadOnlyContent]); // 2) 选中文件变更时加载最新版本 useEffect(() => { if (!selectedId) { useScriptWorkspaceStore.setState({ latestVersion: null }); return; } let ignore = false; useScriptWorkspaceStore.setState({ latestVersionLoading: true }); void loadLatestVersion(selectedId).then(() => { if (!ignore) { useScriptWorkspaceStore.setState({ latestVersionLoading: false }); } }); return () => { ignore = true; }; }, [selectedId, loadLatestVersion]); const selected = useMemo( () => scripts.find((item) => item.script_id === selectedId) ?? null, [scripts, selectedId], ); const filteredScripts = useMemo(() => { const normalized = keyword.trim().toLocaleLowerCase(); if (!normalized) return scripts; return scripts.filter((item) => item.script_name.toLocaleLowerCase().includes(normalized), ); }, [keyword, scripts]); // 3) 自动打开 notebook 编辑器 useEffect(() => { if ( !selected || selected.script_type !== "notebook" || editBusy || (editSession?.script_id === selected.script_id && embeddedJupyterUrl) || editorOpenError?.scriptId === selected.script_id ) { return; } const cached = getSessionCache().get(selected.script_id); if (cached) { useScriptWorkspaceStore.setState({ editSession: cached.session, embeddedJupyterUrl: cached.jupyterUrl, }); return; } void openScriptEditor(selected, false); }, [ selected, editBusy, editSession?.script_id, embeddedJupyterUrl, editorOpenError?.scriptId, openScriptEditor, ]); // 4) context menu 外部点击关闭 useEffect(() => { if (!contextMenu) return; const close = () => closeContextMenu(); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; window.addEventListener("pointerdown", close); window.addEventListener("blur", close); window.addEventListener("resize", close); window.addEventListener("scroll", close, true); window.addEventListener("keydown", closeOnEscape); return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("blur", close); window.removeEventListener("resize", close); window.removeEventListener("scroll", close, true); window.removeEventListener("keydown", closeOnEscape); }; }, [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 const SCRIPT_EXTS = [".py", ".ipynb"]; const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"]; const matchesExt = (name: string, exts: string[]) => exts.some((ext) => name.toLowerCase().endsWith(ext)); const handleUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; if (files.length === 0) return; const scriptFiles = files.filter((f) => matchesExt(f.name, SCRIPT_EXTS)); const dataFiles = files.filter((f) => matchesExt(f.name, DATA_EXTS)); const rejected = files.filter( (f) => !matchesExt(f.name, SCRIPT_EXTS) && !matchesExt(f.name, DATA_EXTS), ); if (rejected.length) { pushToast({ tone: "error", message: `不支持的文件类型: ${rejected.map((f) => f.name).join(", ")}`, }); } if (scriptFiles.length) { void uploadScripts(scriptFiles, upload.parentPath); } if (dataFiles.length === 1) { openDataResourceDialog(dataFiles[0], upload.parentPath); } else if (dataFiles.length > 1) { pushToast({ tone: "info", message: "数据资源一次只支持上传一个文件,请分别上传", }); } }; const handleDataResourceSubmit = (event: FormEvent) => { event.preventDefault(); const { file, resourceName, visibility, description, targetPath } = dataResourceDialog; if (!file || !resourceName.trim()) return; void uploadDataResource(file, { resourceName: resourceName.trim(), visibility, description: description.trim(), targetPath: targetPath.trim(), }).then((resource) => { if (resource) void loadDataResources(); }); }; const handleCopyResourcePath = async (jupyterPath: string) => { if (!jupyterPath) return; const fullPrefix = `${workspaceId ?? ""}/${user?.user_id ?? ""}/`; let stripped = jupyterPath; if (fullPrefix !== "/" && stripped.startsWith(fullPrefix)) { stripped = stripped.slice(fullPrefix.length); } try { await copyToClipboard(stripped); pushToast({ tone: "success", message: `已复制:${stripped}` }); } catch { pushToast({ tone: "error", message: "复制失败" }); } }; const handleCreateSubmit = (event: FormEvent) => { event.preventDefault(); void createScript(createDialog.form); }; const handleFolderSubmit = (event: FormEvent) => { event.preventDefault(); void createFolder(folderDialog.name, folderDialog.parentPath); }; const handlePublishSubmit = (event: FormEvent) => { event.preventDefault(); void submitPublish(publish.releaseNote, publish.visibility); }; const triggerUpload = (parentPath?: string) => { chooseUpload(parentPath); uploadInputRef.current?.click(); }; return (
void load(true)} onUpload={() => triggerUpload("")} onOpenCreateDialog={(parentPath, scriptType) => openCreateDialog(parentPath, scriptType)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath)} onContextMenu={showContextMenu} onSelect={openTab} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} uploadInputRef={uploadInputRef} onHandleUpload={handleUpload} />
{selected ? ( { const s = scripts.find((item) => item.script_id === id); return { scriptId: id, scriptName: s?.script_name ?? "未知", scriptType: s?.script_type ?? "notebook", }; })} onOpenEditor={() => { 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)} onSwitchTab={switchTab} onNewTab={() => openCreateDialog("")} onPublish={() => openPublishDialog(selected)} scripts={scripts} pythonEditorBuffers={pythonEditorBuffers} onOpenPythonEditor={() => void openPythonEditor(selected)} onSetPythonEditorContent={handleSetPythonEditorContent} onSavePythonEditor={handleSavePythonEditor} onExitPythonEditor={handleExitPythonEditor} onClosePythonTab={handleClosePythonTab} onInfo={(t) => pushToast(t)} /> ) : ( openCreateDialog("")} /> )}
({ script_type: s.script_type, script_name: s.script_name, relative_path: s.relative_path, }))} onFormChange={setCreateForm} onSubmit={handleCreateSubmit} onClose={closeCreateDialog} /> { selectScript(scriptId); closeContextMenu(); }} onRemoveScript={(s) => void deleteScript(s)} onToggleLock={(s) => void toggleScriptLock(s)} onOpenCreateDialog={(parentPath, scriptType) => openCreateDialog(parentPath, scriptType)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")} onRemoveDirectory={(p) => void deleteDirectory(p)} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} onRemoveResource={(id) => void deleteDataResource(id)} onClose={closeContextMenu} /> pushToast({ tone: "success", message })} />
); }