// ---- 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(), loadingOwnerGroups: new Set(), 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(); const cacheKey = ownerCacheKey(ownerUserId, parentPath); // 命中缓存:listResources 是非递归的,同一 (owner, parent_path) 拉过的 // 内容不会自己变化;省去 toggleExpanded 重复展开同一目录时的网络往返。 if (get().loadedDataResourcePaths.has(cacheKey)) return; set({ dataResourcesLoading: true }); try { const list = await api.listResources(parentPath, { ownerUserId }); const fresh = Array.isArray(list) ? list : []; set((state) => { // 按 (owner, parent_path) 局部替换:丢掉该 owner 在 parentPath 下的 // 旧条目,保留该 owner 在其它路径下的条目,再并入 fresh。 // 这样 toggleExpanded 在子目录展开时按需拉取不会把根已加载的数据 // 资源擦掉(修"根加载后子目录展开丢数据 / 子目录数据本来不显示")。 const targetOwner = ownerUserId ?? getCurrentUserId() ?? null; const kept = state.dataResources.filter((r) => { if (r.owner_user_id !== targetOwner) return true; return parentPathOf(r.jupyter_accessible_path) !== parentPath; }); const byId = new Map(kept.map((r) => [r.resource_id, r])); for (const item of fresh) byId.set(item.resource_id, item); const nextLoaded = new Set(state.loadedDataResourcePaths); nextLoaded.add(cacheKey); return { dataResources: Array.from(byId.values()), loadedDataResourcePaths: nextLoaded, }; }); } catch { set((state) => { const targetOwner = ownerUserId ?? getCurrentUserId() ?? null; return { dataResources: state.dataResources.filter((r) => { if (r.owner_user_id !== targetOwner) return true; return parentPathOf(r.jupyter_accessible_path) !== parentPath; }), }; }); } 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 }); } } }, }; }; // 提取 jupyter-accessible 路径的父目录;用于 `loadDataResources` 局部替换时 // 判断一条缓存资源是否落在目标 parent_path 下(list-resources 按 parent_path // 精确匹配,非递归)。 function parentPathOf(path: string): string { const parts = path.split("/"); parts.pop(); return parts.join("/"); }