refactor: scriptWorkspaceStore.ts
This commit is contained in:
@@ -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 context(HTTPS / 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 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 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 {
|
||||
// 真实目录展开:loadChildren(owner 限定的显式目录行)+ 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[];
|
||||
// 已拉取根级内容的 owner(loadOwnerGroup 标记),避免重复拉取。
|
||||
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 };
|
||||
Reference in New Issue
Block a user