import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react"; import Icon from "../../components/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 members = useScriptWorkspaceStore((s) => s.members); 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", ); // 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组 // 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取), // 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。 const byOwner = new Map(); 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) { const list = byOwner.get(item.owner_user_id) ?? []; list.push(item); byOwner.set(item.owner_user_id, list); } // 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[]; directories: WorkspaceDirectory[]; dataResources: ResourceItem[]; }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { const groupDataResources = dataByOwner.get(ownerUserId) ?? []; const displayName = memberName.get(ownerUserId) ?? 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, 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: mergeDirectories(ownerDirs, 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?.display_name ?? a.user?.user_id ?? "").localeCompare( b.user?.display_name ?? b.user?.user_id ?? "", ); }); return groups; }, [filteredScripts, directories, user, dataByOwner, members]); 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[], ownerUserId: string, ): 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, owner_user_id: ownerUserId, }); parentPath = path; } } return [...result.values()]; } function mergeDirectories( left: WorkspaceDirectory[], right: WorkspaceDirectory[], ): WorkspaceDirectory[] { return [...new Map( [...left, ...right].map((item) => [item.path, item]), ).values()]; }