import { type MouseEvent as ReactMouseEvent, useEffect, useMemo } from "react"; import { BookOpen, ChevronRight, Database, FileCode, FileJson, FileSpreadsheet, FileText, FileType, Folder, Lock, Table2, type LucideIcon, } from "lucide-react"; import type { ScriptItem, WorkspaceDirectory, ResourceItem, } from "~/services/api"; import { canPreviewDataResource } from "./dataResourcePreview"; export type WorkspaceTreeTarget = { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem; }; type WorkspaceTreeProps = { title: string; scripts: ScriptItem[]; directories: WorkspaceDirectory[]; selectedId: string | null; onSelect: (id: string) => void; onContextMenu?: ( event: ReactMouseEvent, target: WorkspaceTreeTarget, ) => void; readOnly?: boolean; dataResources?: ResourceItem[]; onCopyResourcePath?: (jupyterPath: string) => void; onPreviewResource?: (script: ScriptItem) => void; // 唯一标识此 group(通常 `__group__`),让多个 owner 的 group 各自独立展开。 groupKey: string; // 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人 // 分组展开时走 loadOwnerGroup / 带 owner 的 loadScripts(懒加载)。 ownerUserId: string; expandedPaths: Set; onToggle: (path: string, loadPath?: string, ownerUserId?: string) => void; loadingChildrenPaths: Set; }; type WorkspaceTreeItemsProps = Omit & { path: string; depth: number; onCopyResourcePath?: (jupyterPath: string) => void; }; export type FileVisual = { Icon: LucideIcon; tone: string; }; function formatTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(new Date(value)); } export function scriptIcon(item: Pick): LucideIcon { return item.script_type === "notebook" ? BookOpen : FileCode; } /** 按文件名后缀返回树节点图标与配色(脚本 + 数据资源共用)。 */ export function fileVisualFromName( name: string, scriptType?: ScriptItem["script_type"], ): FileVisual { const lower = name.toLowerCase(); if (scriptType === "notebook" || lower.endsWith(".ipynb")) { return { Icon: BookOpen, tone: "text-[#e15e50] bg-[#fff0ed]" }; } if (scriptType === "python" || lower.endsWith(".py")) { return { Icon: FileCode, tone: "text-[#2e73c6] bg-[#eaf3ff]" }; } if (lower.endsWith(".csv") || lower.endsWith(".tsv")) { return { Icon: Table2, tone: "text-[#2f8f7b] bg-[#eaf8f4]" }; } if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) { return { Icon: FileSpreadsheet, tone: "text-[#3d8f5a] bg-[#eef8f1]" }; } if (lower.endsWith(".json")) { return { Icon: FileJson, tone: "text-[#b07a1a] bg-[#fff8e8]" }; } if (lower.endsWith(".parquet")) { return { Icon: Database, tone: "text-[#4a6fa5] bg-[#eef3fa]" }; } if (lower.endsWith(".txt")) { return { Icon: FileText, tone: "text-[#6b7c8f] bg-[#f2f5f8]" }; } return { Icon: FileType, tone: "text-[#5a8f6a] bg-[#eef8f1]" }; } function ownedScriptPath(item: ScriptItem) { // 数据资源的 relative_path 已经是 jupyter 路径(无 ULID 前缀);脚本相对路径形如 // `{ulid}/{ws_id}/{user_id}/...`,需要剥前两层。 if (item.script_id.startsWith("data:")) { return item.relative_path.replaceAll("\\", "/"); } return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/"); } function parentOf(path: string) { const parts = path.split("/"); parts.pop(); return parts.join("/"); } export function WorkspaceTreeGroup({ title, scripts, directories, selectedId, onSelect, onContextMenu, readOnly = false, dataResources, onCopyResourcePath, onPreviewResource, groupKey, ownerUserId, expandedPaths, onToggle, loadingChildrenPaths, }: WorkspaceTreeProps) { const open = expandedPaths.has(groupKey); const dataResourceScripts = useMemo(() => { if (!dataResources) return []; return dataResources.map((r) => { const ext = r.file.file_extension; const name = r.resource_name; const displayName = ext && !name.toLowerCase().endsWith(ext.toLowerCase()) ? `${name}${ext}` : name; return { script_id: `data:${r.resource_id}`, workspace_id: r.workspace_id, current_object_id: r.storage_object_id, owner_user_id: r.owner_user_id, owner_display_name: null, script_name: displayName, script_type: "python" as const, visibility: r.visibility, status: r.status, is_locked: false, // relative_path 直接是 jupyter 路径(与 Python/Notebook 同层渲染,不再带虚拟前缀) relative_path: r.jupyter_accessible_path, jupyter_path: r.jupyter_accessible_path, content_hash: r.file.content_hash ?? "", size_bytes: r.file.size_bytes, created_at: r.created_at, updated_at: r.updated_at, } as unknown as ScriptItem; }); }, [dataResources]); const dataResourceDirectories = useMemo(() => { if (!dataResources) return []; const dirSet = new Set(); for (const r of dataResources) { const parts = r.jupyter_accessible_path.split("/"); parts.pop(); let acc = ""; for (const p of parts) { acc = acc ? `${acc}/${p}` : p; dirSet.add(acc); } } return [...dirSet].map((path) => ({ path, name: path.split("/").pop() ?? path, parent_path: path.includes("/") ? path.split("/").slice(0, -1).join("/") : "", owner_user_id: ownerUserId, })); }, [dataResources, ownerUserId]); // 默认只展开"我"的分组(!readOnly);其他成员分组默认折叠,点击才 // 按需拉取其可见内容(懒加载设计)。原本对所有 group 无条件 onToggle // 会让所有 owner 的内容在根加载时就被全量拉取,违背"默认只拉取自己的一级"。 useEffect(() => { if (!readOnly && !expandedPaths.has(groupKey)) { void onToggle(groupKey, undefined, ownerUserId); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [groupKey]); return (
{open && (
[d.path, d]), ).values(), ]} selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} expandedPaths={expandedPaths} onToggle={onToggle} loadingChildrenPaths={loadingChildrenPaths} onCopyResourcePath={onCopyResourcePath} onPreviewResource={onPreviewResource} /> {scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (

{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}

)}
)}
); } function WorkspaceTreeItems({ groupKey, ownerUserId, path, depth, scripts, directories, selectedId, onSelect, onContextMenu, onCopyResourcePath, onPreviewResource, expandedPaths, onToggle, loadingChildrenPaths, }: WorkspaceTreeItemsProps) { const childDirectories = directories.filter( (item) => item.parent_path === path, ); const childScripts = scripts.filter( (item) => parentOf(ownedScriptPath(item)) === path, ); return ( <> {childDirectories.map((directory) => ( ))} {childScripts.map((item) => { const isActive = selectedId === item.script_id; const isData = item.script_id.startsWith("data:"); const { Icon: ItemIcon, tone: iconTone } = fileVisualFromName( item.script_name, isData ? undefined : item.script_type, ); return ( ); })} ); } function DirectoryBranch({ groupKey, ownerUserId, directory, depth, scripts, directories, selectedId, onSelect, onContextMenu, onCopyResourcePath, onPreviewResource, expandedPaths, onToggle, loadingChildrenPaths, }: Omit & { directory: WorkspaceDirectory; }) { const expandKey = `${groupKey}/${directory.path}`; const open = expandedPaths.has(expandKey); const childrenLoading = loadingChildrenPaths.has(directory.path); return (
{open && ( )}
); }