// ---- treeSlice ---- // // 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths / // loadedScriptPaths / loadingScriptPaths / loadedDataResourcePaths // (6 个 directory-tree 缓存集合)。负责 toggleExpanded 和 loadChildren。 // // 注意: // - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts / // loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据) // - loadedDataResourcePaths 由 scriptsSlice.loadDataResources 写(同样的 cache // 不放数据原则),toggleExpanded 在真实目录分支按需触发。 // - scriptsSlice.load 也会写 cached script paths,所以这里只保留 toggleExpanded // (写 expandedPaths) 和 loadChildren (写目录缓存)。 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(), loadingChildrenPaths: new Set(), loadedChildPaths: new Set(), loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), loadedDataResourcePaths: new Set(), }; 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__/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 // + loadDataResources 并行。三者都 idempotent + 缓存;ownerUserId // 缺省=我。他人目录同样调 loadChildren(owner) 拉取其目录结构,否则 // 嵌套子目录无法被发现(list_scripts 非递归,只能看到直接子脚本)。 // 数据资源也是非递归的——不按需拉取,子目录里的 csv/xlsx/json 等 // 都不会出现(修"目录树子目录里的数据文件不显示")。 if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) { void get().loadChildren(loadPath, ownerUserId); } if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) { void get().loadScripts(loadPath, ownerUserId); } if ( !state.loadedDataResourcePaths.has( ownerCacheKey(ownerUserId, loadPath), ) ) { void get().loadDataResources(loadPath, ownerUserId); } } } set({ expandedPaths: next }); }, }; };