import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react"; import Icon from "../common/Icon"; import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree"; import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore"; import type { ScriptItem, WorkspaceDirectory } from "~/services/api"; import { type AuthUser } from "~/context/AuthContext"; import type { ResourceItem } from "~/services/api"; type ScriptExplorerProps = { scripts: ScriptItem[]; filteredScripts: ScriptItem[]; directories: WorkspaceDirectory[]; dataResources: ResourceItem[]; user: AuthUser | null; selectedId: string | null; loading: boolean; refreshing: boolean; uploading: boolean; keyword: string; onKeywordChange: (keyword: string) => void; onRefresh: () => void; onUpload: () => void; onOpenCreateDialog: (parentPath?: string, scriptType?: "notebook" | "python") => void; onOpenFolderDialog: (parentPath?: string) => void; onChooseUpload: (parentPath?: string) => void; onContextMenu: ( event: ReactMouseEvent, target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem } ) => void; onSelect: (scriptId: string) => void; onCopyResourcePath: (jupyterPath: string) => void; uploadInputRef: RefObject; onHandleUpload: (event: ChangeEvent) => void; }; export function ScriptExplorer({ scripts, filteredScripts, directories, dataResources, user, selectedId, loading, refreshing, uploading, keyword, onKeywordChange, onRefresh, onUpload, onOpenCreateDialog, onOpenFolderDialog, onChooseUpload, onContextMenu, onSelect, onCopyResourcePath, uploadInputRef, onHandleUpload, }: ScriptExplorerProps) { const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths); const loadingChildrenPaths = useScriptWorkspaceStore( (s) => s.loadingChildrenPaths, ); const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded); const dataByOwner = useMemo(() => { const map = new Map(); for (const r of dataResources) { const list = map.get(r.owner_user_id) ?? []; list.push(r); map.set(r.owner_user_id, list); } return map; }, [dataResources]); const memberScriptGroups = useMemo(() => { const visibleScripts = user?.is_system_admin === true ? filteredScripts : filteredScripts.filter( (item) => item.owner_user_id === user?.user_id || item.visibility === "workspace" || item.visibility === "public", ); const byOwner = new Map(); // 当前用户的目录树即使没有脚本也要渲染,所以预置空组。 if (user?.user_id) { byOwner.set(user.user_id, []); } for (const item of visibleScripts) { const list = byOwner.get(item.owner_user_id) ?? []; list.push(item); byOwner.set(item.owner_user_id, list); } // data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里, // 因为 data resources 与 scripts 共享同一棵目录树。 for (const ownerUserId of dataByOwner.keys()) { if (!byOwner.has(ownerUserId)) { byOwner.set(ownerUserId, []); } } const groups: { user: AuthUser | null; scripts: ScriptItem[]; directories: WorkspaceDirectory[]; dataResources: ResourceItem[]; }[] = []; 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 = groupScripts[0]?.owner_display_name ?? groupDataResources[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? `${ownerUserId.slice(-6)}…`; const groupUser = ownerUserId === user?.user_id ? user : ({ user_id: ownerUserId, username: ownerUserId, display_name: displayName, email: null, status: "unknown", role_code: null, is_system_admin: false, } as AuthUser); const inferred = inferredDirectories(groupScripts); groups.push({ user: groupUser, scripts: groupScripts, directories: ownerUserId === user?.user_id ? mergeDirectories(directories, inferred) : inferred, dataResources: groupDataResources, }); } 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 groups; }, [filteredScripts, directories, user, dataByOwner]); return ( ); } function parentOf(path: string) { const parts = path.split("/"); parts.pop(); return parts.join("/"); } function ownedScriptPath(item: ScriptItem) { return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/"); } function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] { const result = new Map(); 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 }); parentPath = path; } } return [...result.values()]; } function mergeDirectories( left: WorkspaceDirectory[], right: WorkspaceDirectory[], ): WorkspaceDirectory[] { return [...new Map( [...left, ...right].map((item) => [item.path, item]), ).values()]; }