import { DownOutlined } from "@ant-design/icons"; import { Button, Dropdown, Flex, FlexProps, Input, MenuProps, Space, Tree } from "antd"; import { ItemType } from "antd/es/menu/interface"; import { ipcRenderer } from "electron"; import * as fs from "fs"; import React, { FC, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsBoxFill } from "react-icons/bs"; import { FaExclamationTriangle, FaSwatchbook } from "react-icons/fa"; import { FiCommand, FiDelete } from "react-icons/fi"; import { IoMdReturnLeft } from "react-icons/io"; import { PiTreeStructureFill } from "react-icons/pi"; import { useShallow } from "zustand/react/shallow"; import { NodeDef } from "../behavior3/src/behavior3"; import { FileTreeType, useWorkspace } from "../contexts/workspace-context"; import { getNodeType } from "../misc/b3type"; import * as b3util from "../misc/b3util"; import { modal } from "../misc/hooks"; import i18n from "../misc/i18n"; import { Hotkey, isMacos, useKeyPress } from "../misc/keys"; import Path from "../misc/path"; const { DirectoryTree } = Tree; let timeout: NodeJS.Timeout; type MenuInfo = Parameters>[0]; type MenuEvent = | "open" | "newFile" | "newFolder" | "revealFile" | "rename" | "delete" | "paste" | "copy" | "move" | "duplicate"; export type NodeTreeType = { title: string; path: string; def?: NodeDef; icon?: React.ReactNode; isLeaf?: boolean; children?: NodeTreeType[]; style?: React.CSSProperties; }; const findFile = (path: string | undefined, node: FileTreeType): FileTreeType | undefined => { if (!path) { return; } else if (node.path === path) { return node; } if (node.children) { for (const child of node.children) { const ret = findFile(path, child); if (ret) { return ret; } } } }; const findParent = (node: FileTreeType, parent?: FileTreeType): FileTreeType | undefined => { if (parent && parent.children) { if (parent.children?.indexOf(node) >= 0) { return parent; } for (const child of parent.children) { const v = findParent(node, child); if (v) { return v; } } } }; const resolveKeys = (path: string, node: FileTreeType | NodeTreeType, keys: React.Key[]) => { if (node.path === path) { return true; } if (node.children) { keys.push(node.path); for (const child of node.children) { if (resolveKeys(path, child, keys)) { return true; } } keys.pop(); } return false; }; // rename file const renameFile = (oldPath: string, newPath: string) => { const workspace = useWorkspace.getState(); if (fs.existsSync(newPath)) { return false; } else if (oldPath !== newPath) { try { fs.renameSync(oldPath, newPath); const isDirectory = fs.statSync(newPath).isDirectory(); for (const editor of workspace.editors) { if (isDirectory) { if (editor.path.startsWith(oldPath)) { editor.dispatch?.("rename", editor.path.replace(oldPath, newPath)); } } else { if (editor.path === oldPath) { editor.dispatch?.("rename", newPath); } } } return true; } catch (e) { console.error(e); } } return false; }; // file context menu const createFileContextMenu = (node: FileTreeType) => { const isTreeFile = b3util.isTreeFile(node.path); const MenuItem: FC = (itemProps) => { return ( ); }; const arr: MenuProps["items"] = [ { disabled: !isTreeFile, label: (
{i18n.t("open")}
), key: "open", }, { disabled: !isTreeFile, label: (
{i18n.t("copy")}
{isMacos ? "⌘ C" : "Ctrl+C"}
), key: "copy", }, { disabled: !isTreeFile, label: (
{i18n.t("duplicate")}
{isMacos ? "⌘ D" : "Ctrl+D"}
), key: "duplicate", }, { label: (
{isMacos ? i18n.t("revealFileOnMac") : i18n.t("revealFileOnWindows")}
), key: "revealFile", }, { label: (
{i18n.t("rename")}
{isMacos && } {!isMacos &&
F2
}
), key: "rename", }, { label: (
{i18n.t("delete")}
{isMacos && ( )}
), key: "delete", }, ]; return arr; }; // folder context menu const createFolderContextMenu = (copiedPath: string) => { const MenuItem: FC = (itemProps) => { return ( ); }; const arr: MenuProps["items"] = [ { label: (
{i18n.t("newFile")}
), key: "newFile", }, { label: (
{i18n.t("newFolder")}
), key: "newFolder", }, { label: (
{isMacos ? i18n.t("revealFileOnMac") : i18n.t("revealFileOnWindows")}
), key: "revealFile", }, { disabled: !copiedPath, label: (
{i18n.t("paste")}
{isMacos ? "⌘ V" : "Ctrl+V"}
), key: "paste", }, { label: (
{i18n.t("rename")}
{isMacos && } {!isMacos &&
F2
}
), key: "rename", }, { label: (
{i18n.t("delete")}
{isMacos && ( )}
), key: "delete", }, ]; return arr; }; const createNodeTree = (rootName: string, nodeDefs: b3util.NodeDefs) => { const workspace = useWorkspace.getState(); const data: NodeTreeType = { title: i18n.t("nodeDefinition"), path: rootName, icon: ( ), children: [], style: { fontWeight: "bold", fontSize: "13px", }, }; nodeDefs.forEach((nodeDef) => { (nodeDef.group || [""]).forEach((g) => { const typeGroup = !g ? nodeDef.type : `${nodeDef.type} (${g})`; let catalog = data.children?.find((nt) => nt.title === typeGroup); if (!catalog) { const type = getNodeType(nodeDef); catalog = { title: typeGroup, path: `nodeTree.catalog.${typeGroup}`, children: [], icon: ( ), }; data.children?.push(catalog); } catalog.children?.push({ title: `${nodeDef.name}(${nodeDef.desc})`, isLeaf: true, def: nodeDef, path: `${nodeDef.name}(${g})`, icon: ( {nodeDef.icon ? ( ) : ( )} ), }); }); }); data.children?.sort((a, b) => a.title.localeCompare(b.title)); data.children?.forEach((child) => child.children?.sort((a, b) => a.title.localeCompare(b.title))); return data; }; const isElementInViewport = (element: Element, container: Element) => { const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom; }; const scrollIntoView = (path: string) => { clearTimeout(timeout); timeout = setTimeout(() => { const element = document.querySelector(`[title="${path}"]`); const container = element?.closest(".b3-overflow") || element?.closest(".b3-explorer"); if (element && container && !isElementInViewport(element, container)) { element.scrollIntoView({ behavior: "auto", block: "center", }); } }, 300); }; const alertReplaceFile = (path: string, newPath: string) => { const workspace = useWorkspace.getState(); const alert = modal.confirm({ centered: true, content: (
{i18n.t("explorer.replaceFile", { name: Path.basename(newPath) })}
), footer: ( ), }); }; const doMoveFile = (path: string, newPath: string) => { const workspace = useWorkspace.getState(); const title = Path.basename(path); const destDir = Path.dirname(newPath); const doMove = () => { fs.renameSync(path, newPath); for (const editor of workspace.editors) { if (editor.path.startsWith(path)) { editor.dispatch?.("rename", destDir + "/" + Path.basename(editor.path)); } console.log("editor move", editor.path === newPath, editor.path, newPath); if (editor.path.startsWith(newPath)) { console.log("editor reload", editor.path === newPath, editor.path, newPath); editor.dispatch?.("refresh"); } } }; if (!fs.existsSync(newPath)) { doMove(); return; } const alert = modal.confirm({ centered: true, content: (
{i18n.t("explorer.replaceFile", { name: title })}
), footer: ( ), }); }; const alertDeleteFile = (path: string) => { const workspace = useWorkspace.getState(); const title = Path.basename(path); const alert = modal.confirm({ centered: true, content: (
{i18n.t("explorer.deleteFile", { name: title })}
), footer: (
{i18n.t("explorer.restoreFileInfo")}
), }); }; const alertDeleteFolder = (path: string) => { const workspace = useWorkspace.getState(); const title = Path.basename(path); const alert = modal.confirm({ centered: true, content: (
{i18n.t("explorer.deleteFolder", { name: title })}
), footer: (
{i18n.t("explorer.restoreFileInfo")}
), }); }; export const Explorer: FC = () => { const workspace = useWorkspace( useShallow((state) => ({ close: state.close, editing: state.editing, editingNodeDef: state.editingNodeDef, editors: state.editors, fileTree: state.fileTree, nodeDefs: state.nodeDefs, workdir: state.workdir, onEditingNodeDef: state.onEditingNodeDef, open: state.open, })) ); const { t } = useTranslation(); const [selectedKeys, setSelectedKeys] = useState([]); const [expandedKeys, setExpandedKeys] = useState( workspace.fileTree?.path ? [workspace.fileTree.path] : [] ); const [copyFile, setCopyFile] = useState(""); const [newName, setNewName] = useState(null); const [contextMenu, setContextMenu] = useState([]); const rootNodedefName = "nodeTree.root"; const [selectedNodedefKeys, setSelectedNodedefKeys] = useState([]); const [expandedNodedefKeys, setExpandedNodedefKeys] = useState([rootNodedefName]); // set the icon of the root node if (workspace.fileTree) { workspace.fileTree.icon = ( ); } const nodeTree = useMemo( () => createNodeTree(rootNodedefName, workspace.nodeDefs), [t, workspace.nodeDefs] ); // expand the selected tree useEffect(() => { if (workspace.editing) { const keys: React.Key[] = []; resolveKeys(workspace.editing.path, workspace.fileTree!, keys); for (const k of expandedKeys) { if (keys.indexOf(k) === -1) { keys.push(k); } } if (!selectedNodedefKeys.includes(workspace.editing.path)) { scrollIntoView(workspace.editing.path); } setExpandedKeys(keys); setSelectedKeys([workspace.editing.path]); } }, [workspace.editing]); // expand the selected node def useEffect(() => { if (workspace.editingNodeDef) { const def = workspace.editingNodeDef.data; const path = workspace.editingNodeDef.path ?? `${def.name}(${def.group?.[0] ?? ""})`; const keys: React.Key[] = []; resolveKeys(path, nodeTree, keys); for (const k of expandedNodedefKeys) { if (keys.indexOf(k) === -1) { keys.push(k); } } if (!workspace.editingNodeDef.path) { scrollIntoView(path); } setExpandedNodedefKeys(keys); setSelectedNodedefKeys([path]); } }, [t, workspace.editingNodeDef]); const keysRef = useRef(null); useKeyPress([Hotkey.F2, isMacos ? Hotkey.Enter : ""], keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node && node !== workspace.fileTree && !node.editing) { dispatch("rename", node); } }); useKeyPress([Hotkey.Delete, isMacos ? Hotkey.MacDelete : ""], keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node && node !== workspace.fileTree) { dispatch("delete", node); } }); useKeyPress(Hotkey.Escape, keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node) { node.editing = false; setNewName(null); } }); useKeyPress(Hotkey.Duplicate, keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node && node.isLeaf) { dispatch("duplicate", node); } }); useKeyPress(Hotkey.Copy, keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node && node.isLeaf) { dispatch("copy", node); } }); useKeyPress(Hotkey.Paste, keysRef, (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (node) { dispatch("paste", node); } }); const submitRename = (node: FileTreeType) => { if (!newName) { if (fs.existsSync(node.path)) { node.editing = false; } else { const parent = findParent(node, workspace.fileTree); if (parent && parent.children) { parent.children = parent.children.filter((v) => v !== node); } } setNewName(null); return; } if (node.path.endsWith("/:")) { node.path = Path.dirname(node.path) + "/" + newName.replace(/[^\w. _-]+/g, ""); node.title = Path.basename(node.path); fs.mkdirSync(node.path); } else if (node.path.endsWith("/.json")) { node.path = Path.dirname(node.path) + "/" + newName.replace(/[^\w. _-]+/g, ""); node.title = Path.basename(node.path); fs.writeFileSync(node.path, JSON.stringify(b3util.createNewTree(node.title), null, 2)); workspace.open(node.path); } else { const newpath = Path.dirname(node.path) + "/" + newName; if (renameFile(node.path, newpath)) { setSelectedKeys([newpath]); } } node.editing = false; setNewName(null); }; const dispatch = (event: MenuEvent, node: FileTreeType, dest?: FileTreeType) => { switch (event) { case "open": { if (b3util.isTreeFile(node.path)) { workspace.open(node.path); } break; } case "newFolder": { const folderNode: FileTreeType = { path: node.path + "/:", title: "", children: [], editing: true, }; node.children?.unshift(folderNode); setNewName(""); if (expandedKeys.indexOf(node.path) === -1) { setExpandedKeys([node.path, ...expandedKeys]); } break; } case "newFile": { const folderNode: FileTreeType = { path: node.path + "/.json", title: ".json", isLeaf: true, editing: true, }; node.children?.unshift(folderNode); setNewName(""); if (expandedKeys.indexOf(node.path) === -1) { setExpandedKeys([node.path, ...expandedKeys]); } break; } case "paste": { if (copyFile) { let folder = node.path; if (node.isLeaf) { folder = Path.dirname(node.path); } const newPath = folder + "/" + Path.basename(copyFile); if (fs.existsSync(newPath)) { if (node.path === newPath) { dispatch("duplicate", node); } else { alertReplaceFile(node.path, newPath); } } else { fs.copyFileSync(copyFile, newPath); } } break; } case "copy": { if (b3util.isTreeFile(node.path)) { setCopyFile(node.path); } break; } case "duplicate": { if (b3util.isTreeFile(node.path)) { for (let i = 1; ; i++) { const dupName = Path.basenameWithoutExt(node.path) + " " + i + ".json"; const dupPath = Path.dirname(node.path) + "/" + dupName; if (!fs.existsSync(dupPath)) { fs.copyFileSync(node.path, dupPath); setSelectedKeys([dupPath]); break; } } } break; } case "delete": { if (node === workspace.fileTree) { return; } if (node.isLeaf) { alertDeleteFile(node.path); } else { alertDeleteFolder(node.path); } break; } case "move": { try { const destDir = dest?.children ? dest.path : Path.dirname(dest!.path); if (destDir === Path.dirname(node.path)) { return; } const newPath = destDir + "/" + Path.basename(node.path); doMoveFile(node.path, newPath); } catch (error) { console.error("move file:", error); } break; } case "revealFile": ipcRenderer.invoke("show-item-in-folder", node.path); break; case "rename": { if (b3util.isTreeFile(node.path)) { node.editing = true; setNewName(""); } break; } } }; const onClick = (info: MenuInfo) => { const node = findFile(selectedKeys[0], workspace.fileTree!) ?? workspace.fileTree; if (node) { dispatch(info.key as MenuEvent, node); } }; if (!workspace.fileTree) { return null; } return ( { setContextMenu(createFolderContextMenu(copyFile)); }} >
{t("explorer.title")}
{ setExpandedKeys(keys); }} onRightClick={(info) => { if (info.node.isLeaf) { setContextMenu(createFileContextMenu(info.node)); } setSelectedKeys([info.node.path]); }} onSelect={(_, info) => { const node = info.selectedNodes.at(0); if (node && !node.editing) { dispatch("open", node); setSelectedKeys([node.path]); } }} onDrop={(info) => { dispatch("move", info.dragNode, info.node); }} titleRender={(node) => { const value = Path.basename(node.title); if (node.editing) { return (
{ if (value.startsWith(".")) { e.target.setSelectionRange(0, 0); } else { e.target.setSelectionRange(0, value.lastIndexOf(".")); } }} onChange={(e) => setNewName(e.target.value)} onPressEnter={() => { if (newName) { submitRename(node); } }} onClick={(e) => e.stopPropagation()} onKeyUp={(e) => { if (e.code === Hotkey.Escape) { if (!fs.existsSync(node.path)) { const parent = findParent(node, workspace.fileTree); if (parent && parent.children) { parent.children = parent.children.filter((v) => v !== node); } } node.editing = false; setNewName(null); } }} onBlur={() => submitRename(node)} >
); } else { return (
{node.title}
); } }} onDragStart={(e) => { e.event.dataTransfer.setData("explore-file", e.node.path); }} draggable={ newName !== null ? false : { icon: false, nodeDraggable: (node) => { const fileNode = node as unknown as FileTreeType; return !!fileNode.children || b3util.isTreeFile(fileNode.path); }, } } switcherIcon={} />
{ setExpandedNodedefKeys(keys); }} draggable={{ icon: false, nodeDraggable: (node) => !!node.isLeaf }} titleRender={(node) => (
{node.title}
)} onSelect={(_, info) => { const node = info.node; if (node) { if (node.isLeaf) { workspace.onEditingNodeDef({ data: node.def!, path: node.path, }); } setSelectedNodedefKeys([node.path]); } }} onDragStart={(e) => { e.event.dataTransfer.setData("explore-node", e.node.def?.name ?? ""); }} switcherIcon={} />
); };