"use client"; import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { FileTreeRow } from "@/lib/file-tree/types"; import { getFileTreeRowLabel } from "@/lib/file-tree/types"; import { isRealFileAsset } from "@/lib/file-tree/asset"; import { isTextInputTarget } from "@/lib/file-tree/clipboard"; interface FileTreeProps { rows: FileTreeRow[]; activeId: string; selectedRowIds: Set; onRowClick: (row: FileTreeRow, event: React.MouseEvent) => void; onRowDoubleClick: (row: FileTreeRow, event: React.MouseEvent) => void; onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void; onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void; onToggleExpand: (docId: string) => void; onToggleAssetFolderExpand?: (assetId: string) => void; onCreateChild: (parentId: string | null) => void; onBlankMouseDown?: (event: React.MouseEvent) => void; onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void; onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void; } const INDENT = 16; function resolveAssetIconKind(row: Extract) { const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase(); if (assetType === "mindmap") return "mindmap"; if (assetType === "luckysheet") return "table"; const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase(); const fileName = String(row.asset.file_name ?? "").trim().toLowerCase(); const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : ""; if (ext === "pdf" || mimeType.includes("pdf")) return "pdf"; if (ext === "epub" || mimeType.includes("epub")) return "book"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; return "file"; } function renderAssetIcon(row: Extract) { const iconKind = resolveAssetIconKind(row); const baseClass = "w-4 h-4 shrink-0"; switch (iconKind) { case "mindmap": return ; case "table": return ; case "pdf": return ; case "book": return ; case "image": return ; case "video": return ; case "audio": return ; default: return ; } } export function FileTree({ rows, activeId, selectedRowIds, onRowClick, onRowDoubleClick, onRowContextMenu, onRowDragStart, onToggleExpand, onToggleAssetFolderExpand, onCreateChild, onBlankMouseDown, onDropFiles, onInternalDrop, }: FileTreeProps) { const [dragOverRowId, setDragOverRowId] = useState(null); const dragOverRange = useMemo(() => { if (!dragOverRowId) return null; const startIndex = rows.findIndex((row) => row.rowId === dragOverRowId); if (startIndex < 0) return null; const target = rows[startIndex]; const targetDepth = target.depth; // VS Code 的树在拖拽悬停到“展开的文件夹”时,会把该节点的可渲染范围都 // 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows + // depth”来近似计算该范围。 let endIndex = startIndex + 1; if ((target.kind === "doc" || target.kind === "asset-folder") && target.isExpanded && target.hasChildren) { while (endIndex < rows.length && rows[endIndex].depth > targetDepth) { endIndex += 1; } } return { startIndex, endIndex }; }, [dragOverRowId, rows]); if (rows.length === 0) { return
暂无页面,点击下方按钮创建
; } return (
{ if (!onDropFiles) return; if (event.target === event.currentTarget) { setDragOverRowId(null); } const types = Array.from(event.dataTransfer.types ?? []); const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length); if (!hasFiles) return; event.preventDefault(); if (event.dataTransfer.files?.length) { event.dataTransfer.dropEffect = "copy"; } }} onDrop={(event) => { if (onDropFiles && event.dataTransfer.files?.length) { event.preventDefault(); setDragOverRowId(null); const files = event.dataTransfer.files; const activeDocRow = rows.find( (row) => row.kind === "doc" && row.docId === activeId, ); const firstDocRow = rows.find((row) => row.kind === "doc"); const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? ""; onDropFiles(targetDocId, files); } }} onDragLeave={(event) => { const relatedTarget = (event as unknown as { relatedTarget?: EventTarget | null }).relatedTarget; if (relatedTarget && event.currentTarget.contains(relatedTarget as Node)) return; setDragOverRowId(null); }} onMouseDown={(event) => { if (event.target === event.currentTarget) { onBlankMouseDown?.(event); } }} > {rows.map((row, index) => { const label = getFileTreeRowLabel(row); const selected = selectedRowIds.has(row.rowId); // 说明:只有“页面本身(doc/index.md)”才需要 active 高亮。 // 否则当你打开某个页面时,它下面的思维导图文件夹/附件会出现“灰色假选中”的视觉误导。 const active = (row.kind === "doc" || row.kind === "index") && row.docId === activeId; const draggable = row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset)); const inDropFeedback = dragOverRange && index >= dragOverRange.startIndex && index < dragOverRange.endIndex; const baseClass = "group flex items-center gap-2 px-3 py-[2px] min-h-[27px] w-full select-none cursor-pointer rounded-[3px] text-wolai-text-primary text-[14px] font-medium hover:bg-wolai-bg-hover transition-colors duration-150 data-[active=true]:bg-wolai-bg-active"; const activeClass = row.kind === "index" ? "text-[#2563eb]" : row.kind === "doc" ? "text-[#2563eb]" : ""; const paddingLeft = row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32; return (
onRowClick(row, event)} onDoubleClick={(event) => onRowDoubleClick(row, event)} onContextMenu={(event) => onRowContextMenu(row, event)} onMouseDown={(event) => { // 避免 Shift 多选时触发浏览器默认的文本范围选择(VS Code 资源管理器不会出现该行为) if (event.button !== 0) return; if (!event.shiftKey) return; if (isTextInputTarget(event.target)) return; event.preventDefault(); }} draggable={draggable} onDragStart={(event) => { if (!draggable) return; if (!selectedRowIds.has(row.rowId)) { onRowDragStart?.(row, event); } const rowIds = selectedRowIds.has(row.rowId) ? Array.from(selectedRowIds) : [row.rowId]; const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds }); try { event.dataTransfer.setData("application/x-mnote-file-tree", payload); } catch { // ignore } event.dataTransfer.setData("text/plain", payload); event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move"; }} onDragEnd={() => { setDragOverRowId(null); }} onDragOver={(event) => { const types = Array.from(event.dataTransfer.types ?? []); const hasInternal = types.includes("application/x-mnote-file-tree"); if (hasInternal && onInternalDrop) { event.preventDefault(); event.dataTransfer.dropEffect = event.altKey ? "copy" : "move"; setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId)); return; } if (!onDropFiles) return; const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length); if (!hasFiles) return; event.preventDefault(); if (event.dataTransfer.files?.length) { event.dataTransfer.dropEffect = "copy"; } setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId)); }} onDrop={(event) => { setDragOverRowId(null); const types = Array.from(event.dataTransfer.types ?? []); const isInternal = types.includes("application/x-mnote-file-tree"); if (isInternal && onInternalDrop) { const raw = event.dataTransfer.getData("application/x-mnote-file-tree") || ""; try { const parsed = JSON.parse(raw) as { type?: string; version?: number; rowIds?: unknown }; if (parsed?.type === "mnote-file-tree-dnd" && parsed.version === 1 && Array.isArray(parsed.rowIds)) { event.preventDefault(); onInternalDrop({ targetRow: row, rowIds: parsed.rowIds.filter((id) => typeof id === "string") as string[], copy: event.altKey, }); return; } } catch { // ignore } } if (onDropFiles && event.dataTransfer.files?.length) { event.preventDefault(); event.stopPropagation(); onDropFiles(row.docId, event.dataTransfer.files, row); } }} role="button" tabIndex={0} > {row.kind === "doc" ? ( <> {row.hasChildren ? ( ) : ( )} {label} ) : row.kind === "index" ? ( <> {label} ) : row.kind === "asset-folder" ? ( <> {row.hasChildren ? ( ) : ( )} {renderAssetIcon(row)} {label} ) : ( <> {renderAssetIcon(row)} {label} )}
); })}
); }