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:
@@ -60,6 +60,7 @@ export function ScriptExplorer({
|
||||
const loadingChildrenPaths = useScriptWorkspaceStore(
|
||||
(s) => s.loadingChildrenPaths,
|
||||
);
|
||||
const members = useScriptWorkspaceStore((s) => s.members);
|
||||
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
|
||||
|
||||
const dataByOwner = useMemo(() => {
|
||||
@@ -83,9 +84,15 @@ export function ScriptExplorer({
|
||||
item.visibility === "public",
|
||||
);
|
||||
|
||||
// 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组
|
||||
// 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取),
|
||||
// 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。
|
||||
const byOwner = new Map<string, ScriptItem[]>();
|
||||
// 当前用户的目录树即使没有脚本也要渲染,所以预置空组。
|
||||
if (user?.user_id) {
|
||||
for (const m of members) {
|
||||
if (!byOwner.has(m.user_id)) byOwner.set(m.user_id, []);
|
||||
}
|
||||
// 当前用户兜底(members 未就绪时仍渲染"我"的分组)。
|
||||
if (user?.user_id && !byOwner.has(user.user_id)) {
|
||||
byOwner.set(user.user_id, []);
|
||||
}
|
||||
for (const item of visibleScripts) {
|
||||
@@ -93,15 +100,17 @@ export function ScriptExplorer({
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_user_id, list);
|
||||
}
|
||||
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里,
|
||||
// 因为 data resources 与 scripts 共享同一棵目录树。
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户,且不在 members 列表
|
||||
// 里,如已移除成员遗留的资源)也要出现在分组里。
|
||||
for (const ownerUserId of dataByOwner.keys()) {
|
||||
if (!byOwner.has(ownerUserId)) {
|
||||
byOwner.set(ownerUserId, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 成员 id → display_name 优先取 members 列表(最准)。
|
||||
const memberName = new Map(members.map((m) => [m.user_id, m.display_name]));
|
||||
|
||||
const groups: {
|
||||
user: AuthUser | null;
|
||||
scripts: ScriptItem[];
|
||||
@@ -110,9 +119,8 @@ export function ScriptExplorer({
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const groupDataResources = dataByOwner.get(ownerUserId) ?? [];
|
||||
// data-only owner(没有 scripts 的用户)回退到 data resources 的
|
||||
// owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。
|
||||
const displayName =
|
||||
memberName.get(ownerUserId) ??
|
||||
groupScripts[0]?.owner_display_name ??
|
||||
groupDataResources[0]?.owner_display_name ??
|
||||
(ownerUserId === user?.user_id ? user?.display_name : null) ??
|
||||
@@ -129,14 +137,16 @@ export function ScriptExplorer({
|
||||
role_code: null,
|
||||
is_system_admin: false,
|
||||
} as AuthUser);
|
||||
const inferred = inferredDirectories(groupScripts);
|
||||
const inferred = inferredDirectories(groupScripts, ownerUserId);
|
||||
// directories flat 数组现在按 owner_user_id 标记,按 owner 切分后与
|
||||
// inferred 合并(inferred 补全 fetched 目录行未覆盖的祖先路径)。
|
||||
const ownerDirs = directories.filter(
|
||||
(d) => d.owner_user_id === ownerUserId,
|
||||
);
|
||||
groups.push({
|
||||
user: groupUser,
|
||||
scripts: groupScripts,
|
||||
directories:
|
||||
ownerUserId === user?.user_id
|
||||
? mergeDirectories(directories, inferred)
|
||||
: inferred,
|
||||
directories: mergeDirectories(ownerDirs, inferred),
|
||||
dataResources: groupDataResources,
|
||||
});
|
||||
}
|
||||
@@ -144,18 +154,19 @@ export function ScriptExplorer({
|
||||
groups.sort((a, b) => {
|
||||
if (a.user?.user_id === user?.user_id) return -1;
|
||||
if (b.user?.user_id === user?.user_id) return 1;
|
||||
return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? "");
|
||||
return (a.user?.display_name ?? a.user?.user_id ?? "").localeCompare(
|
||||
b.user?.display_name ?? b.user?.user_id ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredScripts, directories, user, dataByOwner]);
|
||||
}, [filteredScripts, directories, user, dataByOwner, members]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
<span>{scripts.length + dataResources.length} 个工作副本</span>
|
||||
</div>
|
||||
<div className="explorer__actions">
|
||||
<button
|
||||
@@ -217,6 +228,7 @@ export function ScriptExplorer({
|
||||
<WorkspaceTreeGroup
|
||||
key={ownerKey}
|
||||
groupKey={`__group__${ownerKey}`}
|
||||
ownerUserId={ownerKey}
|
||||
title={`${group.user?.display_name}`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
@@ -272,14 +284,22 @@ function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||
}
|
||||
|
||||
function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] {
|
||||
function inferredDirectories(
|
||||
items: ScriptItem[],
|
||||
ownerUserId: string,
|
||||
): WorkspaceDirectory[] {
|
||||
const result = new Map<string, WorkspaceDirectory>();
|
||||
for (const item of items) {
|
||||
const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
|
||||
let parentPath = "";
|
||||
for (const name of parts) {
|
||||
const path = parentPath ? `${parentPath}/${name}` : name;
|
||||
result.set(path, { path, name, parent_path: parentPath });
|
||||
result.set(path, {
|
||||
path,
|
||||
name,
|
||||
parent_path: parentPath,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
parentPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,8 @@ export function useApi(): WorkspaceBoundApi {
|
||||
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||
|
||||
return useMemo<WorkspaceBoundApi>(() => ({
|
||||
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
|
||||
listScripts: (parentPath, ownerUserId) =>
|
||||
rawApi.listScripts(workspaceId, parentPath, ownerUserId),
|
||||
countScripts: () => rawApi.countScripts(workspaceId),
|
||||
listResources: (parentPath, opts) =>
|
||||
rawApi.listResources(workspaceId, parentPath, opts),
|
||||
@@ -241,8 +242,8 @@ export function useApi(): WorkspaceBoundApi {
|
||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||
setScriptLock: (scriptId, isLocked) =>
|
||||
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
|
||||
listWorkspaceDirectories: (parentPath?: string) =>
|
||||
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
|
||||
listWorkspaceDirectories: (parentPath, ownerUserId) =>
|
||||
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? "", ownerUserId),
|
||||
createWorkspaceDirectory: (directoryName, parentPath) =>
|
||||
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
|
||||
deleteWorkspaceDirectory: (path) =>
|
||||
|
||||
@@ -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}/`))
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useApi, useAuth } from "../context/AuthContext";
|
||||
import { useEditSessionLifecycle } from "../features/platform/hooks/useEditSessionLifecycle";
|
||||
import {
|
||||
bindScriptWorkspaceApi,
|
||||
bindScriptWorkspaceId,
|
||||
bindScriptWorkspaceUser,
|
||||
editSessionHandle,
|
||||
useScriptWorkspaceStore,
|
||||
} from "../features/platform/state/scriptWorkspaceStore";
|
||||
@@ -88,11 +90,18 @@ function AuthenticatedLayout() {
|
||||
bindScriptWorkspaceApi(api);
|
||||
bindSchedulesApi(api);
|
||||
bindAdminApi(api);
|
||||
// 当前用户 id + 工作区 id 同步绑定到 script workspace store(render body,
|
||||
// 与 bindScriptWorkspaceApi 同理)。store 的 lazy 跨 owner 逻辑据此区分
|
||||
// "我"与他人、并调用需要显式 workspaceId 的 listWorkspaceMembers。
|
||||
bindScriptWorkspaceUser(user?.user_id ?? null);
|
||||
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
bindScriptWorkspaceApi(null);
|
||||
bindSchedulesApi(null);
|
||||
bindAdminApi(null);
|
||||
bindScriptWorkspaceUser(null);
|
||||
bindScriptWorkspaceId(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export type WorkspaceDirectory = {
|
||||
path: string;
|
||||
name: string;
|
||||
parent_path: string;
|
||||
owner_user_id: string;
|
||||
has_children?: boolean;
|
||||
};
|
||||
|
||||
@@ -287,13 +288,21 @@ async function apiRequest<T>(
|
||||
export async function listScripts(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
ownerUserId?: string,
|
||||
): Promise<ScriptItem[]> {
|
||||
// Empty parentPath omits the query string entirely so the backend's
|
||||
// root-level filter is applied symmetrically with non-empty paths.
|
||||
const query = parentPath
|
||||
? `?parent_path=${encodeURIComponent(parentPath)}`
|
||||
: "";
|
||||
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
|
||||
// Default (no ownerUserId) scopes to the requester's own subtree; passing
|
||||
// ownerUserId scopes to that owner's subtree (workspace/public only — the
|
||||
// backend excludes their private) so the tree can lazily fetch another
|
||||
// member's content when their group is expanded.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
|
||||
const query = parameters.toString();
|
||||
return apiRequest<ScriptItem[]>(
|
||||
`/api/v1/scripts${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function countScripts(
|
||||
@@ -439,15 +448,16 @@ export type ResourceItem = {
|
||||
export async function listResources(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
opts?: { visibility?: string; keyword?: string },
|
||||
opts?: { visibility?: string; keyword?: string; ownerUserId?: string },
|
||||
): Promise<ResourceItem[]> {
|
||||
// Empty parentPath omits the query string entirely so the backend's
|
||||
// workspace-wide (root-level) filter is applied symmetrically with
|
||||
// non-empty paths, matching listScripts.
|
||||
// Default (no ownerUserId) scopes to the requester's own object_key
|
||||
// subtree; passing ownerUserId scopes to that owner's subtree so the tree
|
||||
// can lazily fetch another member's data resources on group expand.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (opts?.visibility) parameters.set("visibility", opts.visibility);
|
||||
if (opts?.keyword) parameters.set("keyword", opts.keyword);
|
||||
if (opts?.ownerUserId) parameters.set("owner_user_id", opts.ownerUserId);
|
||||
const query = parameters.toString();
|
||||
// apiRequest<T> already unwraps the envelope's `data` field, so we
|
||||
// request `ResourceItem[]` directly here (matching listScripts).
|
||||
@@ -613,12 +623,18 @@ export async function deleteScript(
|
||||
export async function listWorkspaceDirectories(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
ownerUserId?: string,
|
||||
): Promise<WorkspaceDirectory[]> {
|
||||
const query = parentPath
|
||||
? `?parent_path=${encodeURIComponent(parentPath)}`
|
||||
: "";
|
||||
// Default (no ownerUserId) scopes to the requester's own subtree; passing
|
||||
// ownerUserId scopes to that owner so the tree can lazily render their
|
||||
// directory structure on expand. Directories are structural rows; file
|
||||
// visibility is still enforced by the scripts/data-resources endpoints.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
|
||||
const query = parameters.toString();
|
||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||
`/api/v1/workspace-directories${query}`,
|
||||
`/api/v1/workspace-directories${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
@@ -1487,6 +1503,7 @@ export async function getScheduleNodeRunArtifacts(
|
||||
export type WorkspaceBoundApi = {
|
||||
listScripts: (
|
||||
parentPath?: Parameters<typeof listScripts>[1],
|
||||
ownerUserId?: Parameters<typeof listScripts>[2],
|
||||
) => Promise<ScriptItem[]>;
|
||||
countScripts: () => Promise<number>;
|
||||
listResources: (
|
||||
@@ -1527,7 +1544,10 @@ export type WorkspaceBoundApi = {
|
||||
scriptId: string,
|
||||
isLocked: boolean,
|
||||
) => Promise<ScriptItem>;
|
||||
listWorkspaceDirectories: (parentPath?: string) => Promise<WorkspaceDirectory[]>;
|
||||
listWorkspaceDirectories: (
|
||||
parentPath?: Parameters<typeof listWorkspaceDirectories>[1],
|
||||
ownerUserId?: Parameters<typeof listWorkspaceDirectories>[2],
|
||||
) => Promise<WorkspaceDirectory[]>;
|
||||
createWorkspaceDirectory: (
|
||||
directoryName: string,
|
||||
parentPath?: string,
|
||||
|
||||
Reference in New Issue
Block a user