feat(scripts): 跨 owner 懒加载目录树 + 跨用户可见 workspace/public

修两个后端接口问题:
1) /api/v1/workspace-directories 返回为空,目录树结构消失
2) 同 workspace 内脚本/数据互相可见但默认排除 private

后端改动
--------
* list_scripts / list_resources / list_workspace_directories 新增
  owner_user_id 可选 query 参数;缺省 = 当前请求者本人(scope 到
  workspace/{me}/...),传值时 scope 到该 owner 的子树。前端根加载
  默认只见自己一级,其他成员以折叠分组呈现。
* visibility 过滤统一:非 admin 请求者只返回 owner==me 或
  visibility ∈ {workspace, public};admin 跳过。owner=me 含自己
  的 private,owner=other 只剩其 workspace/public,排除他人 private。
* create_workspace_directory 两个分支 visibility 默认 'public'
  (非 private),使跨 owner 目录树可见;响应新增 owner_user_id 字段。
* platform.list_members 鉴权从 system_admin_context 放宽为
  系统管理员或该 workspace 活跃成员(让普通用户也能渲染同
  workspace 成员名册,用于跨 owner 分组)。
* main.py 注册 platform 模块(随 list_members 改动补齐导入)。
* .env.example 同步 common/config.py 26 个字段。

前端改动
--------
* ScriptExplorer.memberScriptGroups 改由 members 列表播种分组,
  display_name 取 members.display_name;inferredDirectories 现在按
  owner_user_id 标记,统一跨 owner 目录渲染。删除脚本目录页头与
  树分组标题的工作副本数量角标。
* WorkspaceTree 新增 ownerUserId 透传到 store.toggleExpanded;
  仅"我"的分组 mount 时 auto-expand,他人分组默认折叠,展开才
  调 loadOwnerGroup / owner-scoped loadScripts / loadChildren。
* scriptWorkspaceStore 引入 namespaced cache key
  (ownerCacheKey = `${ownerUserId ?? me}:${path}`),loadedScriptPaths
  / loadedChildPaths / loadedOwnerGroups 全部按 owner 隔离;
  toggleExpanded 用 loadPath === undefined 区分 group 头与真实
  目录,修"他人子目录点击不触发接口"的 loadPath 前缀误判 bug。
* api.ts / AuthContext 透传 ownerUserId 给 listScripts /
  listResources / listWorkspaceDirectories。

文档
----
* API.md: §3.2 创建目录 visibility 默认 public + 响应加 owner_user_id;
  §3.3.1 GET directories 加 owner_user_id 参数 + 响应字段;
  §3.4 GET scripts 改写为 owner 作用域 + visibility 过滤语义;
  §五.1 GET data-resources 新增,同一套统一语义;
  §7 intro 例外 — GET members 对系统管理员或 workspace 活跃成员开放。
* DEVELOP.md: Code layout 重写以反映 backend api/services/clients/
  schemas 拆分 + schedule domain/scheduling/application/execution/
  infrastructure 拆分 + common 子包(auth/storage/backends);
  Configuration 系统补全 26 个 settings 字段;新增
  "Owner-scoping + visibility (cross-owner browsing)" 小节;
  Per-service dev 注释用 uv run 的源布局要求;Add a new DAG endpoint /
  storage bucket 路径改为 backend/src/backend/api/* 与 services/*。

测试
----
* test_list_scripts_parent_path.py /
  test_resources.py 补充 owner_user_id 参数化直接调用 + LIKE
  前缀断言(workspace/{owner}/... 前缀)。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-08-21 19:26:53 +08:00
co-authored by Claude
parent 8cf4b53dd4
commit b493907775
15 changed files with 929 additions and 303 deletions
@@ -28,8 +28,11 @@ type WorkspaceTreeProps = {
onCopyResourcePath?: (jupyterPath: string) => void;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string;
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
// 分组展开时走 loadOwnerGroup / 带 owner 的 loadScripts(懒加载)。
ownerUserId: string;
expandedPaths: Set<string>;
onToggle: (path: string, loadPath?: string) => void;
onToggle: (path: string, loadPath?: string, ownerUserId?: string) => void;
loadingChildrenPaths: Set<string>;
};
@@ -79,6 +82,7 @@ export function WorkspaceTreeGroup({
dataResources,
onCopyResourcePath,
groupKey,
ownerUserId,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -125,14 +129,16 @@ export function WorkspaceTreeGroup({
parent_path: path.includes("/")
? path.split("/").slice(0, -1).join("/")
: "",
owner_user_id: ownerUserId,
}));
}, [dataResources]);
}, [dataResources, ownerUserId]);
// 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
// 默认只展开"我"的分组(!readOnly);其他成员分组默认折叠,点击才
// 按需拉取其可见内容(懒加载设计)。原本对所有 group 无条件 onToggle
// 会让所有 owner 的内容在根加载时就被全量拉取,违背"默认只拉取自己的一级"。
useEffect(() => {
if (!expandedPaths.has(groupKey)) {
void onToggle(groupKey);
if (!readOnly && !expandedPaths.has(groupKey)) {
void onToggle(groupKey, undefined, ownerUserId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupKey]);
@@ -142,7 +148,7 @@ export function WorkspaceTreeGroup({
className={`tree-group__title${open ? " is-open" : ""}`}
type="button"
aria-expanded={open}
onClick={() => onToggle(groupKey)}
onClick={() => onToggle(groupKey, undefined, ownerUserId)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
@@ -150,12 +156,12 @@ export function WorkspaceTreeGroup({
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length + (dataResources ?? []).length}</em>
</button>
{open && (
<div className="tree-group__items">
<WorkspaceTreeItems
groupKey={groupKey}
ownerUserId={ownerUserId}
path=""
depth={0}
scripts={[...scripts, ...dataResourceScripts]}
@@ -186,6 +192,7 @@ export function WorkspaceTreeGroup({
function WorkspaceTreeItems({
groupKey,
ownerUserId,
path,
depth,
scripts,
@@ -210,6 +217,7 @@ function WorkspaceTreeItems({
<DirectoryBranch
key={directory.path}
groupKey={groupKey}
ownerUserId={ownerUserId}
directory={directory}
depth={depth}
scripts={scripts}
@@ -271,6 +279,7 @@ function WorkspaceTreeItems({
function DirectoryBranch({
groupKey,
ownerUserId,
directory,
depth,
scripts,
@@ -294,7 +303,7 @@ function DirectoryBranch({
className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }}
type="button"
onClick={() => onToggle(expandKey, directory.path)}
onClick={() => onToggle(expandKey, directory.path, ownerUserId)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "directory",
@@ -312,6 +321,7 @@ function DirectoryBranch({
{open && (
<WorkspaceTreeItems
groupKey={groupKey}
ownerUserId={ownerUserId}
path={directory.path}
depth={depth + 1}
scripts={scripts}
@@ -9,6 +9,7 @@ import type {
Visibility,
WorkspaceBoundApi,
WorkspaceDirectory,
WorkspaceMember,
} from "~/services/api";
import type { NewScriptForm } from "./uiStore";
@@ -41,11 +42,35 @@ 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,不会发到后端)。
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();
@@ -86,10 +111,9 @@ type State = {
expandedPaths: Set<string>;
loadingChildrenPaths: Set<string>;
loadedChildPaths: Set<string>;
// Per-parent-path script cache. Keys are user-relative parent paths;
// values are scripts whose storage path lives directly under that parent.
// The flat `scripts` array above is the union (deduped by script_id) of
// every cache entry that has been loaded in this session.
// Per-owner × parent-path script cache. Keys are namespaced
// `${owner_user_id}:${parent_path}` (see ownerCacheKey) so "我"与他人
// 的同名子目录互不串扰。flat `scripts` 数组是其并集(按 script_id 去重)。
loadedScriptPaths: Set<string>;
loadingScriptPaths: Set<string>;
// Workspace-wide active-script total — separate from the lazy-loaded
@@ -98,6 +122,14 @@ type State = {
scriptCount: number | null;
scriptCountLoading: boolean;
// 工作区成员列表 —— 目录树顶层"我 / user1 / user2 / …"折叠分组的来源。
// 默认只加载"我"的一级目录;其他成员分组折叠,点击才按需拉取
// 其可见(workspace/public)内容、排除其 private。
members: WorkspaceMember[];
// 已拉取根级内容的 ownerloadOwnerGroup 标记),避免重复拉取。
loadedOwnerGroups: Set<string>;
loadingOwnerGroups: Set<string>;
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
readOnlyRefreshVersion: number;
@@ -106,7 +138,7 @@ type State = {
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
loadDataResources: (parentPath?: string) => Promise<void>;
loadDataResources: (parentPath?: string, ownerUserId?: string) => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
@@ -132,11 +164,16 @@ type State = {
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDataResource: (resourceId: string) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
toggleExpanded: (path: string, loadPath?: string) => Promise<void>;
loadChildren: (parentPath: string) => Promise<void>;
// Lazy-load scripts directly under `parentPath`. Idempotent — repeated
// calls for an already-loaded path are no-ops; in-flight calls dedupe.
loadScripts: (parentPath: string) => Promise<void>;
toggleExpanded: (path: string, loadPath?: string, ownerUserId?: string) => Promise<void>;
loadChildren: (parentPath: string, ownerUserId?: string) => Promise<void>;
// Lazy-load scripts directly under `parentPath` for `ownerUserId`(缺省
// = 当前用户)。Idempotent — repeated calls for an already-loaded
// owner×path are no-ops; in-flight calls dedupe.
loadScripts: (parentPath: string, ownerUserId?: string) => Promise<void>;
// 按需拉取某成员的根级可见脚本 + 数据资源(点击其折叠分组时触发)。
// Always fetches (refresh-safe); toggleExpanded 的分组头分支负责守门
// 避免重复拉取,loadedOwnerGroups 标记已加载状态。
loadOwnerGroup: (ownerUserId: string) => Promise<void>;
// Fetch the workspace-wide active-script total. Cheap; the dashboard
// uses this for its hero count so it doesn't depend on lazy-loaded state.
loadScriptCount: () => Promise<void>;
@@ -227,6 +264,10 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
readOnlyRefreshVersion: 0,
setApiOnline: (online) => set({ apiOnline: online }),
@@ -269,6 +310,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
loadingScriptPaths: new Set<string>(),
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
});
},
@@ -277,54 +321,86 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
// Re-fetch every path currently in the cache. On initial mount the
// cache is empty so this degrades to a single root fetch; on
// toolbar refresh / after createFolder / after deleteScript the
// previously-expanded folders are also re-fetched so the UI stays
// consistent (otherwise the cache would keep stale "loaded"
// markers while the corresponding scripts had been replaced by
// the root-only payload, leaving subfolders empty on re-expand).
const cachedScriptPaths = Array.from(get().loadedScriptPaths);
const cachedChildPaths = Array.from(get().loadedChildPaths).filter(
(p) => p !== "",
const me = _currentUserId;
// 拉取工作区成员列表 —— 顶层"我 / 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 =
cachedScriptPaths.length > 0
? cachedScriptPaths.map((p) =>
api.listScripts(p).catch(() => [] as ScriptItem[]),
)
: [api.listScripts("")];
const dirFetches = [
api.listWorkspaceDirectories(""),
...cachedChildPaths.map((p) =>
api.listWorkspaceDirectories(p).catch(() => [] as WorkspaceDirectory[]),
),
];
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 freshScripts = scriptLists.flat();
const freshDirs = dirLists.flat();
// Dedup: later occurrences win so fresh per-path payloads override
// any duplicates coming through different fetch slots.
const myFreshScripts = scriptLists.flat();
// "我"的脚本用 fresh 集合替换;他人脚本原样保留(按 script_id 去重合并)。
const otherScripts = get().scripts.filter(
(s) => s.owner_user_id !== me,
);
const dedupedScripts = Array.from(
new Map(freshScripts.map((s) => [s.script_id, s])).values(),
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(freshDirs.map((d) => [d.path, d])).values(),
new Map(
[...otherDirs, ...dirLists.flat()].map((d) => [
`${d.owner_user_id}:${d.path}`,
d,
]),
).values(),
);
const nextLoadedScripts = new Set(cachedScriptPaths);
nextLoadedScripts.add("");
const nextLoadedScripts = new Set(myCachedScriptKeys);
nextLoadedScripts.add(rootScriptKey);
const nextLoadedChildren = new Set(get().loadedChildPaths);
nextLoadedChildren.add("");
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)) {
@@ -347,33 +423,37 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadScripts: async (parentPath) => {
loadScripts: async (parentPath, ownerUserId) => {
const api = requireApi();
if (get().loadedScriptPaths.has(parentPath)) return;
// Dedupe in-flight requests for the same path.
if (get().loadingScriptPaths.has(parentPath)) return;
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(parentPath);
next.add(cacheKey);
set({ loadingScriptPaths: next });
try {
const items = await api.listScripts(parentPath);
const items = await api.listScripts(parentPath, ownerUserId);
set((state) => {
const existingIds = new Set(state.scripts.map((s) => s.script_id));
const fresh = items.filter((s) => !existingIds.has(s.script_id));
// 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(parentPath);
nextLoaded.add(cacheKey);
return {
scripts: [...state.scripts, ...fresh],
scripts: Array.from(byId.values()),
loadedScriptPaths: nextLoaded,
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
@@ -383,14 +463,96 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadDataResources: async (parentPath = "") => {
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);
set({ dataResources: Array.isArray(list) ? list : [] });
const list = await api.listResources(parentPath, { ownerUserId });
const fresh = Array.isArray(list) ? list : [];
set((state) => {
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 freshfresh 覆盖
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
const targetOwner = ownerUserId ?? _currentUserId ?? 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({ dataResources: [] });
set((state) => {
const targetOwner = ownerUserId ?? _currentUserId ?? null;
return {
dataResources: state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
),
};
});
} finally {
set({ dataResourcesLoading: false });
}
@@ -420,32 +582,40 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadChildren: async (parentPath) => {
loadChildren: async (parentPath, ownerUserId) => {
const api = requireApi();
if (get().loadedChildPaths.has(parentPath)) return;
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedChildPaths.has(cacheKey)) return;
const next = new Set(get().loadingChildrenPaths);
next.add(parentPath);
next.add(cacheKey);
set({ loadingChildrenPaths: next });
try {
const children = await api.listWorkspaceDirectories(parentPath);
const children = await api.listWorkspaceDirectories(parentPath, ownerUserId);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
nextLoaded.add(parentPath);
nextLoaded.add(cacheKey);
// 丢弃该 owner 该 parent 下的旧目录行,再并入 fresh(按 (owner,path) 去重)。
const targetOwner = ownerUserId ?? _currentUserId ?? null;
const trimmed = state.directories.filter(
(d) => d.parent_path !== parentPath,
(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: [...trimmed, ...children],
directories: Array.from(byId.values()),
loadedChildPaths: nextLoaded,
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
@@ -455,7 +625,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
toggleExpanded: async (path, loadPath) => {
toggleExpanded: async (path, loadPath, ownerUserId) => {
const state = get();
const isOpen = state.expandedPaths.has(path);
const next = new Set(state.expandedPaths);
@@ -463,16 +633,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
next.delete(path);
} else {
next.add(path);
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren / loadScripts
const actualLoadPath = loadPath ?? path;
if (!actualLoadPath.startsWith("__group__")) {
// Load both sub-directories and scripts directly under this folder
// in parallel. Both are idempotent + cached; cheap when already loaded.
if (!state.loadedChildPaths.has(actualLoadPath)) {
void get().loadChildren(actualLoadPath);
const me = _currentUserId;
// 用 `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);
}
if (!state.loadedScriptPaths.has(actualLoadPath)) {
void get().loadScripts(actualLoadPath);
} 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);
}
}
}
@@ -1028,16 +1212,25 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
if (parentPath === "") {
await get().load(true);
} else {
const me = _currentUserId ?? "me";
const parentKey = ownerCacheKey(me, parentPath);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
for (const p of state.loadedChildPaths) {
if (p.startsWith(`${parentPath}/`)) nextLoaded.delete(p);
// 仅失效"我"在该 parent 之下的缓存(namespaced key)。
if (p.startsWith(`${me}:`) && p.slice(me.length + 1).startsWith(`${parentPath}/`)) {
nextLoaded.delete(p);
}
}
nextLoaded.delete(parentPath);
nextLoaded.delete(parentKey);
return {
loadedChildPaths: nextLoaded,
directories: state.directories.filter(
(d) => !d.parent_path.startsWith(`${parentPath}/`),
(d) =>
!(
d.owner_user_id === me
&& d.parent_path.startsWith(`${parentPath}/`)
),
),
expandedPaths: new Set(state.expandedPaths),
};
@@ -1124,20 +1317,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
) {
get().selectScript(null);
}
const me = _currentUserId ?? "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) {
if (p === path || p.startsWith(`${path}/`)) nextLoaded.delete(p);
// 仅失效"我"该 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.parent_path !== path && !d.parent_path.startsWith(`${path}/`),
(d) =>
!(
d.owner_user_id === me
&& (d.parent_path === path || d.parent_path.startsWith(`${path}/`))
),
),
};
});