import { FileTreeType, useWorkspace } from "@/contexts/workspace-context"; import { NodeDef, getNodeType } from "@/misc/b3type"; import * as b3util from "@/misc/b3util"; import { modal } from "@/misc/hooks"; import i18n from "@/misc/i18n"; import { Hotkey, isHotkeyPressed, isMacos, useHotkeys } from "@/misc/keys"; import Path from "@/misc/path"; import { DownOutlined } from "@ant-design/icons"; import { Button, Dropdown, Flex, FlexProps, Input, MenuProps, Space, Tree } from "antd"; import { ItemType } from "antd/es/menu/hooks/useItems"; import { ipcRenderer, shell } from "electron"; import * as fs from "fs"; import React, { FC, useEffect, useMemo, 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"; const { DirectoryTree } = Tree; type MenuInfo = Parameters>[0]; type MenuEvent = | "open" | "newFile" | "newFolder" | "revealFile" | "rename" | "delete" | "paste" | "copy" | "move" | "duplicate"; export type NodeTreeType = { title: 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, 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; }; export const Explorer: FC = () => { const workspace = { close: useWorkspace((state) => state.close), editing: useWorkspace((state) => state.editing), editors: useWorkspace((state) => state.editors), fileTree: useWorkspace((state) => state.fileTree), nodeDefs: useWorkspace((state) => state.nodeDefs), workdir: useWorkspace((state) => state.workdir), onEditingNodeDef: useWorkspace((state) => state.onEditingNodeDef), open: useWorkspace((state) => 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([]); if (workspace.fileTree) { workspace.fileTree.icon = ( ); } const nodeTree = useMemo(() => { const data: NodeTreeType = { title: t("nodeDefinition"), icon: ( ), children: [], style: { fontWeight: "bold", fontSize: "13px", }, }; workspace.nodeDefs.forEach((nodeDef) => { let catalog = data.children?.find((nt) => nt.title === nodeDef.type); if (!catalog) { const type = getNodeType(nodeDef); catalog = { title: nodeDef.type, children: [], icon: ( ), }; data.children?.push(catalog); } catalog.children?.push({ title: `${nodeDef.name}(${nodeDef.desc})`, isLeaf: true, def: nodeDef, 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; }, [t, workspace.nodeDefs]); 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); } } setExpandedKeys(keys); setSelectedKeys([workspace.editing.path]); } }, [workspace.editing]); const keysRef = useHotkeys( [ Hotkey.F2, Hotkey.Enter, Hotkey.Copy, Hotkey.Paste, Hotkey.Delete, Hotkey.MacDelete, Hotkey.Escape, Hotkey.Duplicate, ], (event) => { event.preventDefault(); const node = findFile(selectedKeys[0], workspace.fileTree!); if (isHotkeyPressed(Hotkey.F2) || (isMacos && isHotkeyPressed(Hotkey.Enter))) { if (node && node !== workspace.fileTree) { dispatch("rename", node); } } else if (isHotkeyPressed(Hotkey.Delete) || isHotkeyPressed(Hotkey.MacDelete)) { if (node && node !== workspace.fileTree) { dispatch("delete", node); } } else if (isHotkeyPressed(Hotkey.Escape)) { if (node) { node.editing = false; setNewName(null); } } else if (isHotkeyPressed(Hotkey.Duplicate)) { if (node && node.isLeaf) { dispatch("duplicate", node); } } else if (isHotkeyPressed(Hotkey.Copy)) { if (node && node.isLeaf) { dispatch("copy", node); } } else if (isHotkeyPressed(Hotkey.Paste)) { 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 { const alert = modal.confirm({ centered: true, content: (
{t("explorer.replaceFile", { name: Path.basename(copyFile) })}
), footer: ( ), }); } } 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) { const alert = modal.confirm({ centered: true, content: (
{t("explorer.deleteFile", { name: node.title })}
), footer: (
{t("explorer.restoreFileInfo")}
), }); } else { const alert = modal.confirm({ centered: true, content: (
{t("explorer.deleteFolder", { name: node.title })}
), footer: (
{t("explorer.restoreFileInfo")}
), }); } 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); const doMove = () => { fs.renameSync(node.path, newPath); for (const editor of workspace.editors) { if (editor.path.startsWith(node.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("reload"); } } }; if (fs.existsSync(newPath)) { const alert = modal.confirm({ centered: true, content: (
{t("explorer.replaceFile", { name: Path.basename(node.path) })}
), footer: ( ), }); } else { doMove(); } } catch (error) { console.error("move file:", error); } break; } case "revealFile": ipcRenderer.invoke("showItemInFolder", 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) { 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); }} defaultExpandedKeys={[workspace.fileTree.path]} draggable={ newName !== null ? false : { icon: false, nodeDraggable: (node) => { const fileNode = node as unknown as FileTreeType; return !!fileNode.children || b3util.isTreeFile(fileNode.path); }, } } expandedKeys={expandedKeys} switcherIcon={} selectedKeys={selectedKeys} />
!!node.isLeaf }} titleRender={(node) => (
{node.title}
)} onSelect={(_, info) => { const node = info.node; if (node && node.isLeaf) { workspace.onEditingNodeDef({ data: node.def!, }); } }} onDragStart={(e) => { e.event.dataTransfer.setData("explore-node", e.node.def?.name ?? ""); }} switcherIcon={} />
); };