refactor: scriptWorkspaceStore.ts

This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 8278cdd8b0
commit e9d9bcfc97
12 changed files with 2133 additions and 1446 deletions
@@ -0,0 +1,227 @@
// ---- editSessionSlice ----
//
// 拥有 editSession / embeddedJupyterUrl / editBusy / editorOpenError。
// Actions: openScriptEditor / endEditing / tickCleanup / releaseActiveOnUnload。
// tickCleanup/releaseActiveOnUnload 放在这里因为它们都围绕 editSession 的生命周期。
//
// 模块级状态 (_editSession / sessionCache / _editorOpening /
// _editorOpenRequest / _api) 通过 helpers 访问。
import type { StateCreator } from "zustand";
import type { ActiveEditSession, ScriptItem } from "~/services/api";
import {
applyEditSessionState,
bumpEditorOpenRequest,
getApi,
getEditorOpening,
getEditorOpenRequest,
getSelectedId,
pushToast,
requireApi,
sessionCache,
setEditorOpening,
} from "./helpers";
import type {
EditSessionSliceActions,
EditSessionSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createEditSessionSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
EditSessionSliceState & EditSessionSliceActions
> = (set, get) => {
const initial: EditSessionSliceState = {
editSession: null,
embeddedJupyterUrl: null,
editBusy: false,
editorOpenError: null,
};
return {
...initial,
openScriptEditor: async (script: ScriptItem, showToast = true) => {
if (!script) return;
if (getEditorOpening()) return;
setEditorOpening(true);
const api = requireApi();
const requestId = getEditorOpenRequest() + 1;
// 记录本次请求的 id (供 requestIsCurrent 校验)。
// 原版用模块级 _editorOpenRequest 配合闭包变量;现在 requestIsCurrent
// 通过 helpers 读取最新的模块级值 + getSelectedId()。
const requestIsCurrent = () =>
getEditorOpenRequest() === requestId &&
getSelectedId() === script.script_id;
const clearSessionIfActive = (session: ActiveEditSession) => {
const active = get().editSession;
if (active?.edit_session_id === session.edit_session_id) {
applyEditSessionState((p) => set(p), null, null);
}
};
set({ editBusy: true });
set((state) => ({
editorOpenError:
state.editorOpenError?.scriptId === script.script_id
? null
: state.editorOpenError,
}));
try {
const cached = sessionCache.get(script.script_id);
if (cached) {
if (!requestIsCurrent()) return;
applyEditSessionState(
(p) => set(p),
cached.session,
cached.jupyterUrl,
);
if (showToast) {
pushToast(
"success",
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
);
}
return;
}
let active = get().editSession;
let newlyAcquired = false;
if (active && active.script_id !== script.script_id) {
await api.releaseFileLock(active);
applyEditSessionState((p) => set(p), null, null);
active = null;
}
if (!requestIsCurrent()) return;
if (!active) {
active = await api.acquireFileLock(script);
newlyAcquired = true;
}
if (!requestIsCurrent()) {
if (active) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const ticket = await api.createJupyterAccessTicket(active);
if (!requestIsCurrent()) {
if (newlyAcquired) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const readySession = {
...active,
ticket_expires_at: ticket.expires_at,
};
applyEditSessionState(
(p) => set(p),
readySession,
ticket.jupyter_url,
);
sessionCache.set(script.script_id, {
session: readySession,
jupyterUrl: ticket.jupyter_url,
lastActiveTime: Date.now(),
});
if (showToast) {
pushToast(
"success",
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
);
}
} catch (error) {
applyEditSessionState((p) => set(p), null, null);
if (requestIsCurrent()) {
const message =
error instanceof Error ? error.message : "打开编辑器失败";
set({ editorOpenError: { scriptId: script.script_id, message } });
if (showToast) {
pushToast("error", message);
}
}
} finally {
setEditorOpening(false);
set({ editBusy: false });
}
},
endEditing: async (closeTabFlag = true, showToast = true) => {
const api = requireApi();
// bump editorOpenRequest 让未完成的 openScriptEditor 失效
bumpEditorOpenRequest();
set({ editorOpenError: null });
const active = get().editSession;
const scriptId = active?.script_id;
if (!active) {
if (closeTabFlag && scriptId) {
await get().closeTab(scriptId);
}
return;
}
set({ editBusy: true });
try {
await api.releaseFileLock(active);
applyEditSessionState((p) => set(p), null, null);
if (scriptId) sessionCache.delete(scriptId);
if (closeTabFlag && scriptId) {
await get().closeTab(scriptId);
}
if (showToast) {
pushToast("success", `${active.script_name} 的编辑锁已释放`);
}
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "释放编辑锁失败",
);
} finally {
set({ editBusy: false });
}
},
tickCleanup: () => {
// 本地锁:清缓存即可,不要发 releaseFileLock 请求。
// 接口是异步的,但在本地实现里等价于无操作,promise 没人 await。
const TEN_MINUTES = 10 * 60 * 1000;
const now = Date.now();
const toCleanup: string[] = [];
const selectedId = getSelectedId();
for (const [scriptId, cached] of sessionCache.entries()) {
if (scriptId !== selectedId) {
const inactiveTime = now - cached.lastActiveTime;
if (inactiveTime > TEN_MINUTES) {
toCleanup.push(scriptId);
}
}
}
if (toCleanup.length === 0) return;
for (const scriptId of toCleanup) {
sessionCache.delete(scriptId);
}
pushToast(
"info",
`已清理 ${toCleanup.length} 个长时间未活动的本地编辑会话`,
);
},
releaseActiveOnUnload: () => {
const api = getApi();
if (!api) return;
const current = get().editSession;
if (current) {
api.releaseFileLockOnUnload(current);
}
},
};
};
@@ -0,0 +1,227 @@
// ---- Module-level non-reactive holders ----
// 这些变量不放在 store 里 (避免 React 重渲 + 跨切片共享单例)。
// 每一个切片在 helpers.ts 顶层导入它们,确保所有 slice 看到的同一份。
import type {
ActiveEditSession,
ScriptItem,
WorkspaceBoundApi,
} from "~/services/api";
import { useUiStore } from "./uiStore";
// 缓存的会话类型(多 iframe 共存方案)
type CachedSession = {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
};
export type PythonEditorBuffer = {
initialContent: string | null;
content: string | null;
dirty: boolean;
saving: boolean;
initial: boolean;
loadError: string | null;
};
// 模块级可变 holder(非响应式,避免 React 重渲)
export const sessionCache = new Map<string, CachedSession>();
let _selectedId: string | null = null;
let _editSession: ActiveEditSession | null = null;
let _editorOpening = false;
let _editorOpenRequest = 0;
let _api: WorkspaceBoundApi | null = null;
let _previewController: AbortController | null = null;
let _previewRequest = 0;
let _pythonEditorOpeningIds = new Set<string>();
let _scriptCountSeq = 0;
// 当前登录用户 id —— 与 `_api` 一样由 layout 在 render body 绑定。
// 用于:(1) `loadScripts`/`loadOwnerGroup` 区分"我"与他人;
// (2) `toggleExpanded` 判定展开真实目录时是否需要 loadChildren(他人的
// 目录全靠脚本路径推断,不调 listWorkspaceDirectories)。
let _currentUserId: string | null = null;
// 当前工作区 id —— listWorkspaceMembers 不像 listScripts 那样把 workspaceId
// 烤进 bound api,需要显式传入,故由 layout 绑定。
let _workspaceId: string | null = null;
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
_api = api;
};
export const bindScriptWorkspaceUser = (userId: string | null) => {
_currentUserId = userId;
};
export const bindScriptWorkspaceId = (workspaceId: string | null) => {
_workspaceId = workspaceId;
};
// `loadedScriptPaths` / `loadedChildPaths` 的 key 命名空间:
// `${owner_user_id}:${parent_path}`,让"我"与他人的同名子目录缓存互不串扰。
// owner 缺省时回退当前用户,再回退字面量 "me"(仅作占位 key,不会发到后端)。
export function ownerCacheKey(
ownerUserId: string | undefined,
path: string,
): string {
const owner = ownerUserId ?? _currentUserId ?? "me";
return `${owner}:${path}`;
}
export const getSessionCache = () => sessionCache;
export const clearSessionCache = () => {
sessionCache.clear();
};
// handle ref 给 Sidebar 用,避免订阅 store
export const editSessionHandle: { current: ActiveEditSession | null } = {
current: null,
};
// ---- 跨切片共享的内部 getters (供切片实现使用) ----
export function getApi(): WorkspaceBoundApi | null {
return _api;
}
export function getCurrentUserId(): string | null {
return _currentUserId;
}
export function getWorkspaceId(): string | null {
return _workspaceId;
}
export function getSelectedId(): string | null {
return _selectedId;
}
export function setSelectedId(value: string | null): void {
_selectedId = value;
}
export function getEditSession(): ActiveEditSession | null {
return _editSession;
}
export function getEditorOpening(): boolean {
return _editorOpening;
}
export function setEditorOpening(value: boolean): void {
_editorOpening = value;
}
export function getEditorOpenRequest(): number {
return _editorOpenRequest;
}
export function bumpEditorOpenRequest(): number {
_editorOpenRequest += 1;
return _editorOpenRequest;
}
export function getPreviewController(): AbortController | null {
return _previewController;
}
export function setPreviewController(value: AbortController | null): void {
_previewController = value;
}
export function bumpPreviewRequest(): number {
_previewRequest += 1;
return _previewRequest;
}
export function getPreviewRequest(): number {
return _previewRequest;
}
export function getPythonEditorOpeningIds(): Set<string> {
return _pythonEditorOpeningIds;
}
export function bumpScriptCountSeq(): number {
_scriptCountSeq += 1;
return _scriptCountSeq;
}
export function getScriptCountSeq(): number {
return _scriptCountSeq;
}
// ---- 工具函数 (原模块级 helper, 不依赖 set/get) ----
export function requireApi(): WorkspaceBoundApi {
if (!_api) {
throw new Error("script workspace API 未绑定");
}
return _api;
}
export function ownedScriptPath(item: ScriptItem) {
return item.relative_path
.replaceAll("\\", "/")
.split("/")
.slice(2)
.join("/");
}
export function pushToast(
tone: "success" | "error" | "info",
message: string,
): void {
useUiStore.getState().pushToast({ tone, message });
}
export async function sha256Hex(
buffer: ArrayBuffer,
): Promise<string | null> {
// `crypto.subtle` 仅在 secure contextHTTPS / localhost)下可用;不可用时
// 跳过 hash 计算,让后端走非校验路径。
if (typeof crypto === "undefined" || !crypto.subtle?.digest) {
return null;
}
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
// ---- setEditSessionState (跨切片 helper, 原 store 闭包内 function) ----
//
// 必须在每个使用它的 slice 里手动传 `set`,因为 store 不再是单 closure。
// 内部更新模块级 `_editSession` + `editSessionHandle.current`,再 `set` 切片 state。
export function applyEditSessionState(
set: (partial: {
editSession: ActiveEditSession | null;
embeddedJupyterUrl: string | null;
}) => void,
next: ActiveEditSession | null,
nextJupyterUrl: string | null,
): void {
_editSession = next;
editSessionHandle.current = next;
set({
editSession: next,
embeddedJupyterUrl: next ? nextJupyterUrl : null,
});
}
// ---- 模块级 reset (供 useScriptWorkspaceStore.reset 调用) ----
//
// 同步模块级可变状态 + abort preview controller;不写响应式 state(state 由根 reset 处理)。
export function resetModuleState(): void {
if (_previewController) {
_previewController.abort();
_previewController = null;
}
_previewRequest += 1;
_selectedId = null;
_editSession = null;
editSessionHandle.current = null;
sessionCache.clear();
_pythonEditorOpeningIds.clear();
}
@@ -0,0 +1,429 @@
// ---- mutationsSlice ----
//
// 拥有 latestVersion / latestVersionLoading。
// 所有写后端的 action (CRUD + 发布 + 锁) 集中在这里。
//
// cross-slice 写:
// - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice)
// - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) +
// 调 load (scriptsSlice)
// - uploadDataResource 不写 store state (只走 uiStore)
// - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) +
// 调 loadChildren / load (treeSlice/scriptsSlice)
// - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调
// endEditing (editSessionSlice) + 调 load (scriptsSlice)
// - deleteDataResource 写 dataResources (scriptsSlice)
// - deleteDirectory 写 loadedChildPaths/expandedPaths/directories (treeSlice)
// + 调 selectScript (selectionSlice) + 调 loadChildren / load
// - toggleScriptLock 写 scripts (scriptsSlice)
// - submitPublish 不写 store state (只走 uiStore)
// - openPublishDialog 委托 uiStore (selectionSlice 也有,这里只放空,实际由
// selectionSlice.openPublishDialog 提供——不要重复定义,这里只保留版本相关)
import type { StateCreator } from "zustand";
import type {
ResourceItem,
ScriptItem,
StableVersion,
Visibility,
} from "~/services/api";
import { useUiStore } from "./uiStore";
import {
getCurrentUserId,
getEditSession,
getSelectedId,
ownedScriptPath,
ownerCacheKey,
pushToast,
requireApi,
sha256Hex,
} from "./helpers";
import type { NewScriptForm } from "./uiStore";
import type {
DataResourceMeta,
MutationsSliceActions,
MutationsSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createMutationsSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
MutationsSliceState & MutationsSliceActions
> = (set, get) => {
const initial: MutationsSliceState = {
latestVersion: null,
latestVersionLoading: false,
};
return {
...initial,
loadLatestVersion: async (scriptId) => {
const api = requireApi();
set({ latestVersionLoading: true });
try {
const item = await api.getLatestScriptVersion(scriptId);
set({ latestVersion: item });
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "最新版本加载失败",
);
} finally {
set({ latestVersionLoading: false });
}
},
createScript: async (form: NewScriptForm) => {
const api = requireApi();
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
const requestedName = form.name.trim();
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
? requestedName
: `${requestedName}${suffix}`;
const duplicate = get().scripts.some((script) => {
if (script.script_type !== form.scriptType) return false;
if (script.script_name.toLocaleLowerCase()
!== normalizedName.toLocaleLowerCase()) return false;
// Same name in a different subdirectory is allowed: mirror the
// backend name_clash check, which JOINs StorageObjects and scopes
// by relative_path.
const existingUserPath = ownedScriptPath(script);
const existingParent = existingUserPath.includes("/")
? existingUserPath.slice(0, existingUserPath.lastIndexOf("/"))
: "";
return existingParent === form.parentPath;
});
if (duplicate) {
pushToast("error", `${normalizedName} 已存在,请更换名称`);
return null;
}
const ui = useUiStore.getState();
ui.setCreating(true);
try {
const created = await api.createScript(form);
set((state) => ({ scripts: [created, ...state.scripts] }));
get().openTab(created.script_id);
ui.closeCreateDialog();
pushToast("success", `${created.script_name} 已创建`);
return created;
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "创建失败",
);
return null;
} finally {
ui.setCreating(false);
}
},
uploadScripts: async (files, parentPath) => {
const api = requireApi();
const ui = useUiStore.getState();
ui.setUploading(true);
let lastCreated: ScriptItem | null = null;
try {
for (const file of files) {
lastCreated = await api.uploadScript(file, parentPath, "workspace");
}
set((state) => ({ scripts: [lastCreated!, ...state.scripts] }));
if (lastCreated) {
get().openTab(lastCreated.script_id);
}
pushToast(
"success",
`${files.length} 个文件已上传到${parentPath ? ` ${parentPath}` : "当前目录"}`,
);
} catch (error) {
await get().load(true);
pushToast(
"error",
error instanceof Error ? error.message : "文件上传失败",
);
} finally {
ui.setUploading(false);
}
},
uploadDataResource: async (file: File, meta: DataResourceMeta) => {
const api = requireApi();
const ui = useUiStore.getState();
ui.setDataResourceUploading(true);
try {
const buffer = await file.arrayBuffer();
const hash = await sha256Hex(buffer);
const { upload_id: uploadId } = await api.createResourceUpload({
file_name: file.name,
content_type: file.type || "application/octet-stream",
expected_size_bytes: file.size,
expected_hash: hash,
target_path: meta.targetPath,
});
await api.uploadResourceBytes(
uploadId,
buffer,
file.type || "application/octet-stream",
);
const resource = await api.bindResourceUpload(uploadId, {
resource_name: meta.resourceName,
description: meta.description,
visibility: meta.visibility,
});
ui.closeDataResourceDialog();
pushToast(
"success",
`数据资源 ${resource.resource_name} 上传成功,路径:${resource.jupyter_accessible_path}`,
);
return resource;
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "数据资源上传失败",
);
return null;
} finally {
ui.setDataResourceUploading(false);
}
},
deleteDataResource: async (resourceId) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) {
return;
}
try {
await api.deleteResource(resourceId);
set((state) => ({
dataResources: state.dataResources.filter(
(r) => r.resource_id !== resourceId,
),
}));
pushToast("success", "数据资源已删除");
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "数据资源删除失败",
);
}
},
createFolder: async (name, parentPath) => {
const api = requireApi();
const trimmed = name.trim();
if (!trimmed) return;
const ui = useUiStore.getState();
ui.setFolderBusy(true);
try {
await api.createWorkspaceDirectory(trimmed, parentPath);
if (parentPath === "") {
await get().load(true);
} else {
const me = getCurrentUserId() ?? "me";
const parentKey = ownerCacheKey(me, parentPath);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
for (const p of state.loadedChildPaths) {
// 仅失效"我"在该 parent 之下的缓存(namespaced key)。
if (
p.startsWith(`${me}:`) &&
p.slice(me.length + 1).startsWith(`${parentPath}/`)
) {
nextLoaded.delete(p);
}
}
nextLoaded.delete(parentKey);
return {
loadedChildPaths: nextLoaded,
directories: state.directories.filter(
(d) =>
!(
d.owner_user_id === me
&& d.parent_path.startsWith(`${parentPath}/`)
),
),
expandedPaths: new Set(state.expandedPaths),
};
});
await get().loadChildren(parentPath);
}
ui.closeFolderDialog();
pushToast("success", `${trimmed} 文件夹已创建`);
} catch (error) {
ui.setFolderBusy(false);
pushToast(
"error",
error instanceof Error ? error.message : "文件夹创建失败",
);
}
},
deleteScript: async (script: ScriptItem) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
) {
return;
}
const editSession = getEditSession();
if (editSession?.script_id === script.script_id) {
await get().endEditing(false, false);
if (getEditSession()?.script_id === script.script_id) return;
}
try {
await api.deleteScript(script.script_id);
set((state) => {
const index = state.openTabIds.indexOf(script.script_id);
const newTabs = state.openTabIds.filter(
(id) => id !== script.script_id,
);
if (getSelectedId() === script.script_id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
return { openTabIds: newTabs, selectedId: nextId };
}
return { openTabIds: newTabs };
});
await get().load(true);
pushToast("success", `${script.script_name} 已删除`);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "文件删除失败",
);
}
},
deleteDirectory: async (path) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (
!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)
) {
return;
}
const activeScript = get().scripts.find(
(item) => item.script_id === getEditSession()?.script_id,
);
if (
activeScript
&& (ownedScriptPath(activeScript) === path
|| ownedScriptPath(activeScript).startsWith(`${path}/`))
) {
await get().endEditing(false, false);
if (getEditSession()?.script_id === activeScript.script_id) return;
}
const parentPath = path.includes("/")
? path.split("/").slice(0, -1).join("/")
: "";
try {
const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = get().scripts.find(
(item) => item.script_id === getSelectedId(),
);
if (
selectedScript
&& ownedScriptPath(selectedScript).startsWith(`${path}/`)
) {
get().selectScript(null);
}
const me = getCurrentUserId() ?? "me";
const pathKey = ownerCacheKey(me, path);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
const nextExpanded = new Set(state.expandedPaths);
for (const p of state.loadedChildPaths) {
// 仅失效"我"该 path 及其子目录的缓存(namespaced key)。
if (!p.startsWith(`${me}:`)) continue;
const bare = p.slice(me.length + 1);
if (bare === path || bare.startsWith(`${path}/`)) {
nextLoaded.delete(p);
}
}
for (const p of state.expandedPaths) {
if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p);
}
void pathKey;
return {
loadedChildPaths: nextLoaded,
expandedPaths: nextExpanded,
directories: state.directories.filter(
(d) =>
!(
d.owner_user_id === me
&& (d.parent_path === path
|| d.parent_path.startsWith(`${path}/`))
),
),
};
});
if (parentPath === "") {
await get().load(true);
} else {
await get().loadChildren(parentPath);
}
pushToast(
"success",
`${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "文件夹删除失败",
);
}
},
toggleScriptLock: async (script: ScriptItem) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
try {
const updated = await api.setScriptLock(script.script_id, !script.is_locked);
set((state) => ({
scripts: state.scripts.map((s) =>
s.script_id === updated.script_id ? updated : s,
),
}));
pushToast(
"success",
`${updated.script_name}${updated.is_locked ? "锁定" : "解锁"}`,
);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "锁定状态更新失败",
);
}
},
submitPublish: async (releaseNote: string, visibility: Visibility) => {
const api = requireApi();
const ui = useUiStore.getState();
const target = ui.publish.target;
if (!target) return;
ui.setPublishing(true);
try {
const version: StableVersion = await api.publishScriptVersion({
script: target,
releaseNote,
visibility,
});
ui.setPublishedVersion(version);
pushToast("success", `${version.version_label} 稳定版本发布成功`);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "稳定版本发布失败",
);
} finally {
ui.setPublishing(false);
}
},
};
};
@@ -0,0 +1,90 @@
// ---- previewSlice ----
//
// 拥有 previewKey / previewCode / previewCodeSize / previewLoading / previewError。
// Action: loadPreview (用 AbortController 取消旧请求 + request-id 序列号忽略过期响应)。
import type { StateCreator } from "zustand";
import {
bumpPreviewRequest,
getPreviewController,
getPreviewRequest,
setPreviewController,
} from "./helpers";
import type {
PreviewSliceActions,
PreviewSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createPreviewSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
PreviewSliceState & PreviewSliceActions
> = (set) => {
const initial: PreviewSliceState = {
previewKey: null,
previewCode: null,
previewCodeSize: null,
previewLoading: false,
previewError: null,
};
return {
...initial,
loadPreview: async (workspaceId, filePath) => {
const existing = getPreviewController();
if (existing) {
existing.abort();
}
const requestId = bumpPreviewRequest();
const previewKey = `${workspaceId}::${filePath}`;
set({ previewKey, previewLoading: true, previewError: null });
const controller = new AbortController();
setPreviewController(controller);
try {
const url =
`/jupyter/${workspaceId}/api/contents/${filePath}` +
`?type=file&content=1&hash=1&format=text`;
const response = await fetch(url, {
signal: controller.signal,
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status}`);
}
const data = await response.json();
if (getPreviewRequest() !== requestId) return;
set({
previewKey,
previewCode: data.content ?? "",
previewCodeSize: data.size ?? 0,
previewLoading: false,
previewError: null,
});
} catch (error) {
if (getPreviewRequest() !== requestId) return;
if (error instanceof Error && error.name === "AbortError") return;
set({
previewKey,
previewCode: null,
previewCodeSize: null,
previewLoading: false,
previewError:
error instanceof Error ? error.message : "加载预览失败",
});
} finally {
if (getPreviewRequest() === requestId) {
setPreviewController(null);
}
}
},
};
};
@@ -0,0 +1,175 @@
// ---- pythonEditorSlice ----
//
// 拥有 pythonEditorBuffers (per-scriptId 临时编辑缓冲)。
// Actions: openPythonEditor / setPythonEditorContent / savePythonEditor /
// exitPythonEditor / exitAllPythonEditors。
// 模块级 _pythonEditorOpeningIds (去重 in-flight open) 通过 helpers 访问。
import type { StateCreator } from "zustand";
import type { ScriptItem } from "~/services/api";
import {
getPythonEditorOpeningIds,
pushToast,
requireApi,
} from "./helpers";
import type {
PythonEditorSliceActions,
PythonEditorSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createPythonEditorSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
PythonEditorSliceState & PythonEditorSliceActions
> = (set, get) => {
const initial: PythonEditorSliceState = {
pythonEditorBuffers: {},
};
return {
...initial,
openPythonEditor: async (script: ScriptItem, showToast = true) => {
if (!script) return;
const opening = getPythonEditorOpeningIds();
if (opening.has(script.script_id)) return;
opening.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,
},
},
}));
if (showToast) {
pushToast("error", message);
}
} finally {
opening.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: () => {
const opening = getPythonEditorOpeningIds();
opening.clear();
set({ pythonEditorBuffers: {} });
},
};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
// ---- scriptsSlice ----
//
// 拥有 scripts / directories / dataResources / scriptCount / members /
// loadedOwnerGroups / loading / refreshing / apiOnline / readOnlyRefreshVersion。
// 所有 "从后端拉数据" 的 action 集中在这里(load / loadScripts /
// loadOwnerGroup / loadDataResources / loadScriptCount / refreshReadOnlyContent /
// setApiOnline)。
//
// cross-slice 依赖:
// - load 调用 selectionSlice 的 selectedId/openTabIds (清空失效项)
// - load 调用 treeSlice 的 loadedScriptPaths/loadedChildPaths/loadedOwnerGroups
// - loadScripts 读/写 treeSlice 的 loadedScriptPaths/loadingScriptPaths
// - loadOwnerGroup 读/写 treeSlice 的 loadedScriptPaths/loadedChildPaths
// - loadDataResources 读 helpers 的 _currentUserId
import type { StateCreator } from "zustand";
import type {
ResourceItem,
ScriptItem,
WorkspaceDirectory,
WorkspaceMember,
} from "~/services/api";
import {
bumpScriptCountSeq,
getCurrentUserId,
getScriptCountSeq,
getWorkspaceId,
ownerCacheKey,
pushToast,
requireApi,
} from "./helpers";
import type {
ScriptsSliceActions,
ScriptsSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createScriptsSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
ScriptsSliceState & ScriptsSliceActions
> = (set, get) => {
const initial: ScriptsSliceState = {
scripts: [],
directories: [],
dataResources: [],
dataResourcesLoading: false,
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
loading: true,
refreshing: false,
apiOnline: false,
readOnlyRefreshVersion: 0,
};
return {
...initial,
setApiOnline: (online) => set({ apiOnline: online }),
refreshReadOnlyContent: () =>
set((state) => ({
readOnlyRefreshVersion: state.readOnlyRefreshVersion + 1,
})),
load: async (silent = false) => {
const api = requireApi();
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
const me = getCurrentUserId();
const workspaceId = getWorkspaceId();
// 拉取工作区成员列表 —— 顶层"我 / user1 / user2 / …"折叠分组的来源。
const memberList =
workspaceId != null
? await api
.listWorkspaceMembers(workspaceId)
.catch(() => [] as WorkspaceMember[])
: [];
// 只重新拉取"我"的已缓存脚本路径(含根)。首次挂载缓存为空 → 退化为
// 单次根级拉取。他人脚本不动(保留在 flat scripts 里,见下方合并)。
const myCachedScriptKeys = Array.from(get().loadedScriptPaths).filter(
(k) => k.startsWith(`${me}:`) || k.startsWith("me:"),
);
const rootScriptKey = ownerCacheKey(undefined, "");
const scriptFetches =
myCachedScriptKeys.length > 0
? myCachedScriptKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api.listScripts(p).catch(() => [] as ScriptItem[]);
})
: [api.listScripts("").catch(() => [] as ScriptItem[])];
// 只重新拉取"我"的已缓存目录路径(含根)。他人目录不动(保留在
// flat directories 里,按 (owner,path) 去重合并)。
const myCachedDirKeys = Array.from(get().loadedChildPaths).filter(
(k) => (me != null && k.startsWith(`${me}:`)) || k.startsWith("me:"),
);
const rootDirKey = ownerCacheKey(undefined, "");
const dirFetches =
myCachedDirKeys.length > 0
? myCachedDirKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api
.listWorkspaceDirectories(p)
.catch(() => [] as WorkspaceDirectory[]);
})
: [api
.listWorkspaceDirectories("")
.catch(() => [] as WorkspaceDirectory[])];
const [scriptLists, dirLists] = await Promise.all([
Promise.all(scriptFetches),
Promise.all(dirFetches),
]);
const myFreshScripts = scriptLists.flat();
// "我"的脚本用 fresh 集合替换;他人脚本原样保留(按 script_id 去重合并)。
const otherScripts = get().scripts.filter(
(s) => s.owner_user_id !== me,
);
const dedupedScripts = Array.from(
new Map(
[...otherScripts, ...myFreshScripts].map((s) => [s.script_id, s]),
).values(),
);
// "我"的目录用 fresh 集合替换;他人目录原样保留(按 (owner,path) 去重)。
const otherDirs = get().directories.filter(
(d) => d.owner_user_id !== me,
);
const dedupedDirs = Array.from(
new Map(
[...otherDirs, ...dirLists.flat()].map((d) => [
`${d.owner_user_id}:${d.path}`,
d,
]),
).values(),
);
const nextLoadedScripts = new Set(myCachedScriptKeys);
nextLoadedScripts.add(rootScriptKey);
const nextLoadedChildren = new Set(get().loadedChildPaths);
nextLoadedChildren.add(rootDirKey);
set({
scripts: dedupedScripts,
directories: dedupedDirs,
members: memberList,
apiOnline: true,
loadedScriptPaths: nextLoadedScripts,
loadedChildPaths: nextLoadedChildren,
});
// 刷新已展开的其他成员分组(loadOwnerGroup 总是发起请求,刷新安全)。
for (const owner of get().loadedOwnerGroups) {
if (owner !== me) void get().loadOwnerGroup(owner);
}
const validIds = new Set(dedupedScripts.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
set({ selectedId: null });
}
set((state) => ({
openTabIds: state.openTabIds.filter((id) => validIds.has(id)),
}));
} catch (error) {
set({ apiOnline: false });
pushToast(
"error",
error instanceof Error ? error.message : "脚本列表加载失败",
);
} finally {
set({ loading: false, refreshing: false });
}
},
loadScripts: async (parentPath, ownerUserId) => {
const api = requireApi();
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedScriptPaths.has(cacheKey)) return;
// Dedupe in-flight requests for the same owner×path.
if (get().loadingScriptPaths.has(cacheKey)) return;
const next = new Set(get().loadingScriptPaths);
next.add(cacheKey);
set({ loadingScriptPaths: next });
try {
const items = await api.listScripts(parentPath, ownerUserId);
set((state) => {
// append-only 合并(按 script_id 去重,fresh 覆盖 stale 同 id 值)。
// 该 owner×path 的全量刷新由 load()(我)/ loadOwnerGroup(他人)
// 负责 drop-by-owner 后重并入;这里是子目录展开,append 即可。
const byId = new Map(state.scripts.map((s) => [s.script_id, s]));
for (const item of items) byId.set(item.script_id, item);
const nextLoaded = new Set(state.loadedScriptPaths);
nextLoaded.add(cacheKey);
return {
scripts: Array.from(byId.values()),
loadedScriptPaths: nextLoaded,
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
"error",
error instanceof Error ? error.message : "脚本列表加载失败",
);
}
},
loadOwnerGroup: async (ownerUserId) => {
const api = requireApi();
if (get().loadingOwnerGroups.has(ownerUserId)) return;
const nextLoading = new Set(get().loadingOwnerGroups);
nextLoading.add(ownerUserId);
set({ loadingOwnerGroups: nextLoading });
try {
const [scripts, resources, directories] = await Promise.all([
api.listScripts("", ownerUserId).catch(() => [] as ScriptItem[]),
api.listResources("", { ownerUserId }).catch(
() => [] as ResourceItem[],
),
api.listWorkspaceDirectories("", ownerUserId).catch(
() => [] as WorkspaceDirectory[],
),
]);
set((state) => {
// 丢弃该 owner 的旧脚本/资源/目录(按 owner 过滤后保留他人),再并入 fresh。
const keptScripts = state.scripts.filter(
(s) => s.owner_user_id !== ownerUserId,
);
const keptResources = state.dataResources.filter(
(r) => r.owner_user_id !== ownerUserId,
);
const keptDirs = state.directories.filter(
(d) => d.owner_user_id !== ownerUserId,
);
const scriptIds = new Set(keptScripts.map((s) => s.script_id));
const freshScripts = scripts.filter((s) => !scriptIds.has(s.script_id));
const resourceIds = new Set(keptResources.map((r) => r.resource_id));
const freshResources = resources.filter(
(r) => !resourceIds.has(r.resource_id),
);
const dirIds = new Set(
keptDirs.map((d) => `${d.owner_user_id}:${d.path}`),
);
const freshDirs = directories.filter(
(d) => !dirIds.has(`${d.owner_user_id}:${d.path}`),
);
const nextLoaded = new Set(state.loadedOwnerGroups);
nextLoaded.add(ownerUserId);
const nextScriptPaths = new Set(state.loadedScriptPaths);
nextScriptPaths.add(ownerCacheKey(ownerUserId, ""));
const nextChildPaths = new Set(state.loadedChildPaths);
nextChildPaths.add(ownerCacheKey(ownerUserId, ""));
return {
scripts: [...keptScripts, ...freshScripts],
dataResources: [...keptResources, ...freshResources],
directories: [...keptDirs, ...freshDirs],
loadedOwnerGroups: nextLoaded,
loadedScriptPaths: nextScriptPaths,
loadedChildPaths: nextChildPaths,
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
};
});
} catch {
set((state) => ({
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
}));
}
},
loadDataResources: async (parentPath = "", ownerUserId) => {
const api = requireApi();
set({ dataResourcesLoading: true });
try {
const list = await api.listResources(parentPath, { ownerUserId });
const fresh = Array.isArray(list) ? list : [];
set((state) => {
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 freshfresh 覆盖
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
const kept = state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
);
const byId = new Map(kept.map((r) => [r.resource_id, r]));
for (const item of fresh) byId.set(item.resource_id, item);
return { dataResources: Array.from(byId.values()) };
});
} catch {
set((state) => {
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
return {
dataResources: state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
),
};
});
} finally {
set({ dataResourcesLoading: false });
}
},
loadScriptCount: async () => {
const api = requireApi();
// Always fire — don't dedupe via the loading flag. Rapid workspace
// switches would otherwise drop the new fetch and leave the
// dashboard showing the previous workspace's count. The sequence
// counter below discards stale responses instead.
const seq = bumpScriptCountSeq();
set({ scriptCountLoading: true, scriptCount: null });
try {
const total = await api.countScripts();
if (getScriptCountSeq() !== seq) return; // a newer fetch superseded us
set({ scriptCount: total });
} catch {
if (getScriptCountSeq() !== seq) return;
// Leave previous value in place; the dashboard already tolerates
// a stale count by rendering `scriptCount ?? 0`. Don't toast —
// the dashboard's other metrics are best-effort.
} finally {
if (getScriptCountSeq() === seq) {
set({ scriptCountLoading: false });
}
}
},
};
};
@@ -0,0 +1,128 @@
// ---- selectionSlice ----
//
// 拥有 selectedId / openTabIds / keyword + 选择/标签页/搜索关键词相关 action。
// cross-slice 依赖: closeTab 调用 pythonEditorSlice.exitPythonEditor +
// editSessionSlice.endEditing + helpers.sessionCache;
// switchTab 触发 pythonEditorSlice.savePythonEditor + helpers.applyEditSessionState
// (恢复缓存中的 editSession);
// openPublishDialog 委托 uiStore。
import type { StateCreator } from "zustand";
import type { ScriptItem } from "~/services/api";
import {
applyEditSessionState,
bumpEditorOpenRequest,
getSelectedId,
sessionCache,
setSelectedId,
} from "./helpers";
import { useUiStore } from "./uiStore";
import type {
SelectionSliceActions,
SelectionSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createSelectionSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
SelectionSliceState & SelectionSliceActions
> = (set, get) => {
const initial: SelectionSliceState = {
selectedId: null,
openTabIds: [],
keyword: "",
};
return {
...initial,
setKeyword: (keyword) => set({ keyword }),
selectScript: (id) => {
setSelectedId(id);
set({ selectedId: id });
},
openTab: (id) => {
const current = getSelectedId();
if (current !== id) {
bumpEditorOpenRequest();
set({ editorOpenError: null });
}
setSelectedId(id);
set((state) => ({
selectedId: id,
openTabIds: state.openTabIds.includes(id)
? state.openTabIds
: [...state.openTabIds, id],
}));
},
closeTab: async (id, event) => {
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);
}
const editSession = get().editSession;
if (editSession?.script_id === id) {
await get().endEditing(false, false);
}
sessionCache.delete(id);
set((state) => {
const index = state.openTabIds.indexOf(id);
if (index === -1) return {};
const newTabs = state.openTabIds.filter((tabId) => tabId !== id);
let nextSelected = state.selectedId;
if (state.selectedId === id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
setSelectedId(nextId);
nextSelected = nextId;
}
return { openTabIds: newTabs, selectedId: nextSelected };
});
},
switchTab: (id) => {
const current = get().selectedId;
if (current && current !== id) {
const curBuffer = get().pythonEditorBuffers[current];
if (curBuffer?.dirty && !curBuffer.saving) {
void get().savePythonEditor(current);
}
}
const selected = getSelectedId();
if (selected !== id) {
bumpEditorOpenRequest();
set({ editorOpenError: null });
}
setSelectedId(id);
set({ selectedId: id });
const cached = sessionCache.get(id);
if (cached) {
cached.lastActiveTime = Date.now();
// set 来自 StateCreator, 接受 Partial<ScriptWorkspaceStore>。
// applyEditSessionState 接受更窄的 partial;通过函数协变兼容。
applyEditSessionState(
(p) => set(p),
cached.session,
cached.jupyterUrl,
);
}
},
openPublishDialog: (script: ScriptItem) => {
useUiStore.getState().openPublishDialog(script);
},
};
};
@@ -0,0 +1,127 @@
// ---- treeSlice ----
//
// 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths /
// loadedScriptPaths / loadingScriptPaths (5 个 directory-tree 缓存集合)。
// 负责 toggleExpanded 和 loadChildren。
//
// 注意:
// - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts /
// loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据)
// - scriptsSlice.load 也会写这俩,所以这里只保留这两个 setter (toggleExpanded
// 也要写 expandedPaths)。
import type { StateCreator } from "zustand";
import {
getCurrentUserId,
ownerCacheKey,
pushToast,
requireApi,
} from "./helpers";
import type { TreeSliceActions, TreeSliceState } from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createTreeSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
TreeSliceState & TreeSliceActions
> = (set, get) => {
const initial: TreeSliceState = {
expandedPaths: new Set<string>(),
loadingChildrenPaths: new Set<string>(),
loadedChildPaths: new Set<string>(),
loadedScriptPaths: new Set<string>(),
loadingScriptPaths: new Set<string>(),
};
return {
...initial,
loadChildren: async (parentPath, ownerUserId) => {
const api = requireApi();
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedChildPaths.has(cacheKey)) return;
const next = new Set(get().loadingChildrenPaths);
next.add(cacheKey);
set({ loadingChildrenPaths: next });
try {
const children = await api.listWorkspaceDirectories(parentPath, ownerUserId);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
nextLoaded.add(cacheKey);
// 丢弃该 owner 该 parent 下的旧目录行,再并入 fresh(按 (owner,path) 去重)。
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
const trimmed = state.directories.filter(
(d) =>
!(d.owner_user_id === targetOwner && d.parent_path === parentPath),
);
const byId = new Map(
trimmed.map((d) => [`${d.owner_user_id}:${d.path}`, d]),
);
for (const c of children) byId.set(`${c.owner_user_id}:${c.path}`, c);
return {
directories: Array.from(byId.values()),
loadedChildPaths: nextLoaded,
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
"error",
error instanceof Error ? error.message : "目录加载失败",
);
}
},
toggleExpanded: async (path, loadPath, ownerUserId) => {
const state = get();
const isOpen = state.expandedPaths.has(path);
const next = new Set(state.expandedPaths);
if (isOpen) {
next.delete(path);
} else {
next.add(path);
const me = getCurrentUserId();
// 用 `loadPath === undefined` 区分分组头与真实目录,而不是用
// `path.startsWith("__group__")`:目录的 expandKey 是
// `${groupKey}/${dir.path}` 即 `__group__<owner>/dir`,同样以
// `__group__` 开头,前缀判断会把子目录点击误当成分组头,导致
// 既不调 loadChildren 也不调 loadScripts"子目录点击不触发接口")。
// 分组头 always 传 loadPath=undefined;目录 always 传 loadPath=dir.path。
if (loadPath === undefined) {
// 分组头:他人分组首次展开 → loadOwnerGroup 按需拉取其根级可见
// 脚本+数据+目录(守门去重)。仅翻转 expand;真实子目录的懒加载
// 由目录分支(loadPath !== undefined)负责。
if (
ownerUserId &&
ownerUserId !== me &&
!state.loadedOwnerGroups.has(ownerUserId)
) {
void get().loadOwnerGroup(ownerUserId);
}
} else {
// 真实目录展开:loadChildrenowner 限定的显式目录行)+ loadScripts
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
// list_scripts 非递归,只能看到直接子脚本)。
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
void get().loadChildren(loadPath, ownerUserId);
}
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
void get().loadScripts(loadPath, ownerUserId);
}
}
}
set({ expandedPaths: next });
},
};
};
@@ -0,0 +1,175 @@
// ---- Slice State / Action type declarations ----
//
// 每个 slice 导出自己的 `*SliceState` / `*SliceActions` 类型。
// 根组合类型 `ScriptWorkspaceStore` 在 `useScriptWorkspaceStore.ts`,
// 切片用 `import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore"`
// 循环引用(type-only import 在编译期被擦除)。
import type {
ActiveEditSession,
LatestVersion,
ResourceItem,
ScriptItem,
Visibility,
WorkspaceDirectory,
WorkspaceMember,
} from "~/services/api";
import type { NewScriptForm } from "./uiStore";
import type { PythonEditorBuffer } from "./helpers";
// ---- scriptsSlice ----
export type ScriptsSliceState = {
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
dataResourcesLoading: boolean;
// Workspace-wide active-script total — separate from the lazy-loaded
// `scripts` array so dashboards don't underreport. `null` until the
// first loadScriptCount() resolves; the count endpoint is cheap.
scriptCount: number | null;
scriptCountLoading: boolean;
// 工作区成员列表 —— 目录树顶层"我 / user1 / user2 / …"折叠分组的来源。
members: WorkspaceMember[];
// 已拉取根级内容的 ownerloadOwnerGroup 标记),避免重复拉取。
loadedOwnerGroups: Set<string>;
loadingOwnerGroups: Set<string>;
loading: boolean;
refreshing: boolean;
apiOnline: boolean;
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
readOnlyRefreshVersion: number;
};
export type ScriptsSliceActions = {
setApiOnline: (online: boolean) => void;
load: (silent?: boolean) => Promise<void>;
loadScripts: (parentPath: string, ownerUserId?: string) => Promise<void>;
loadOwnerGroup: (ownerUserId: string) => Promise<void>;
loadDataResources: (parentPath?: string, ownerUserId?: string) => Promise<void>;
loadScriptCount: () => Promise<void>;
refreshReadOnlyContent: () => void;
};
// ---- treeSlice ----
export type TreeSliceState = {
expandedPaths: Set<string>;
loadingChildrenPaths: Set<string>;
loadedChildPaths: Set<string>;
// Per-owner × parent-path script cache. Keys are namespaced
// `${owner_user_id}:${parent_path}` (see ownerCacheKey).
loadedScriptPaths: Set<string>;
loadingScriptPaths: Set<string>;
};
export type TreeSliceActions = {
toggleExpanded: (
path: string,
loadPath?: string,
ownerUserId?: string,
) => Promise<void>;
loadChildren: (parentPath: string, ownerUserId?: string) => Promise<void>;
};
// ---- selectionSlice ----
export type SelectionSliceState = {
selectedId: string | null;
openTabIds: string[];
keyword: string;
};
export type SelectionSliceActions = {
setKeyword: (keyword: string) => void;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (
id: string,
event?: { stopPropagation: () => void },
) => Promise<void>;
switchTab: (id: string) => void;
openPublishDialog: (script: ScriptItem) => void;
};
// ---- editSessionSlice ----
export type EditSessionSliceState = {
editSession: ActiveEditSession | null;
embeddedJupyterUrl: string | null;
editBusy: boolean;
editorOpenError: { scriptId: string; message: string } | null;
};
export type EditSessionSliceActions = {
openScriptEditor: (
script: ScriptItem,
showToast?: boolean,
) => Promise<void>;
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
tickCleanup: () => void;
releaseActiveOnUnload: () => void;
};
// ---- previewSlice ----
export type PreviewSliceState = {
previewKey: string | null;
previewCode: string | null;
previewCodeSize: number | null;
previewLoading: boolean;
previewError: string | null;
};
export type PreviewSliceActions = {
loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
};
// ---- pythonEditorSlice ----
export type PythonEditorSliceState = {
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
};
export type PythonEditorSliceActions = {
openPythonEditor: (
script: ScriptItem,
showToast?: boolean,
) => Promise<void>;
setPythonEditorContent: (scriptId: string, value: string) => void;
savePythonEditor: (scriptId: string) => Promise<void>;
exitPythonEditor: (scriptId: string) => void;
exitAllPythonEditors: () => void;
};
// ---- mutationsSlice ----
export type MutationsSliceState = {
latestVersion: LatestVersion | null;
latestVersionLoading: boolean;
};
export type DataResourceMeta = {
resourceName: string;
visibility: Visibility;
description: string;
targetPath: string;
};
export type MutationsSliceActions = {
loadLatestVersion: (scriptId: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
uploadScripts: (files: File[], parentPath: string) => Promise<void>;
uploadDataResource: (
file: File,
meta: DataResourceMeta,
) => Promise<ResourceItem | null>;
createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDataResource: (resourceId: string) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
toggleScriptLock: (script: ScriptItem) => Promise<void>;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
};
@@ -0,0 +1,174 @@
// ---- 根 store 组合 ----
//
// 把 7 个 slice (`scripts` / `tree` / `selection` / `editSession` /
// `preview` / `pythonEditor` / `mutations`) 拼装成一个 zustand store,
// 公开 `bindApi` 和 `reset` 两个跨 slice 的全局 action。
//
// 公开 API 通过 re-export 全部保持 (`useScriptWorkspaceStore` /
// `bindScriptWorkspaceApi` / `bindScriptWorkspaceUser` /
// `bindScriptWorkspaceId` / `getSessionCache` / `clearSessionCache` /
// `editSessionHandle` / `PythonEditorBuffer`),consumer 无需改 import。
import { create } from "zustand";
import type { WorkspaceBoundApi } from "~/services/api";
import {
bindScriptWorkspaceApi,
clearSessionCache,
editSessionHandle,
getSessionCache,
resetModuleState,
} from "./helpers";
import type { PythonEditorBuffer } from "./helpers";
import { createScriptsSlice } from "./scriptsSlice";
import type {
ScriptsSliceActions,
ScriptsSliceState,
} from "./types";
import { createTreeSlice } from "./treeSlice";
import type { TreeSliceActions, TreeSliceState } from "./types";
import { createSelectionSlice } from "./selectionSlice";
import type {
SelectionSliceActions,
SelectionSliceState,
} from "./types";
import { createEditSessionSlice } from "./editSessionSlice";
import type {
EditSessionSliceActions,
EditSessionSliceState,
} from "./types";
import { createPreviewSlice } from "./previewSlice";
import type {
PreviewSliceActions,
PreviewSliceState,
} from "./types";
import { createPythonEditorSlice } from "./pythonEditorSlice";
import type {
PythonEditorSliceActions,
PythonEditorSliceState,
} from "./types";
import { createMutationsSlice } from "./mutationsSlice";
import type {
MutationsSliceActions,
MutationsSliceState,
} from "./types";
// ---- Combined types (re-used by each slice's StateCreator generic) ----
export type ScriptWorkspaceState =
& ScriptsSliceState
& TreeSliceState
& SelectionSliceState
& EditSessionSliceState
& PreviewSliceState
& PythonEditorSliceState
& MutationsSliceState;
export type ScriptWorkspaceActions =
& ScriptsSliceActions
& TreeSliceActions
& SelectionSliceActions
& EditSessionSliceActions
& PreviewSliceActions
& PythonEditorSliceActions
& MutationsSliceActions
& {
bindApi: (api: WorkspaceBoundApi | null) => void;
reset: () => void;
};
export type ScriptWorkspaceStore = ScriptWorkspaceState & ScriptWorkspaceActions;
// ---- Initial values (used by reset() to mirror the legacy monolithic state) ----
//
// 完整枚举所有 slice 字段 + 跨 slice reset 时需要重置的 loading 状态。
// reset() 由根组合负责,不能在 slice 里做 (跨 slice)。
const INITIAL: ScriptWorkspaceState = {
// scriptsSlice
scripts: [],
directories: [],
dataResources: [],
dataResourcesLoading: false,
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
loading: true,
refreshing: false,
apiOnline: false,
readOnlyRefreshVersion: 0,
// treeSlice
expandedPaths: new Set<string>(),
loadingChildrenPaths: new Set<string>(),
loadedChildPaths: new Set<string>(),
loadedScriptPaths: new Set<string>(),
loadingScriptPaths: new Set<string>(),
// selectionSlice
selectedId: null,
openTabIds: [],
keyword: "",
// editSessionSlice
editSession: null,
embeddedJupyterUrl: null,
editBusy: false,
editorOpenError: null,
// previewSlice
previewKey: null,
previewCode: null,
previewCodeSize: null,
previewLoading: false,
previewError: null,
// pythonEditorSlice
pythonEditorBuffers: {},
// mutationsSlice
latestVersion: null,
latestVersionLoading: false,
};
export const useScriptWorkspaceStore = create<ScriptWorkspaceStore>()(
(set, get, store) => ({
...createScriptsSlice(set, get, store),
...createTreeSlice(set, get, store),
...createSelectionSlice(set, get, store),
...createEditSessionSlice(set, get, store),
...createPreviewSlice(set, get, store),
...createPythonEditorSlice(set, get, store),
...createMutationsSlice(set, get, store),
// ---- 跨 slice 全局 actions ----
bindApi: (api) => {
bindScriptWorkspaceApi(api);
},
reset: () => {
// 1) 重置模块级可变状态 (preview controller abort + session cache 等)
resetModuleState();
// 2) 重置响应式 state —— `INITIAL` 是根文件定义的副本,包含所有 slice 的初值
// (loading 默认 true,和原版 `set({ ...initial, loading: true })` 一致)。
set({ ...INITIAL });
},
}),
);
// ---- 公开 API re-export ----
//
// 保持与原 monolith 文件相同的导出表面,consumer 无需改动 import path。
export {
bindScriptWorkspaceApi,
// bindScriptWorkspaceUser 与 bindScriptWorkspaceId 由 routes/platform.tsx 直接使用,
// 也从这里 re-export 保持向后兼容。
} from "./helpers";
export { bindScriptWorkspaceUser, bindScriptWorkspaceId } from "./helpers";
export { getSessionCache, clearSessionCache, editSessionHandle };
export type { PythonEditorBuffer };