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:
@@ -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[];
|
||||
// 已拉取根级内容的 owner(loadOwnerGroup 标记),避免重复拉取。
|
||||
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 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 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 {
|
||||
// 真实目录展开: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}/`))
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user