////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Scripting/Script_Editor.jsx import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Paper, Box, Typography, Button, Menu, MenuItem, Select, FormControl, InputLabel, TextField } from "@mui/material"; import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; import { SimpleTreeView, TreeItem, useTreeViewApiRef } from "@mui/x-tree-view"; // Reuse shared dialogs for consistency import { ConfirmDeleteDialog, RenameFolderDialog } from "../Dialogs"; // Prism for syntax highlighting (same theme as JSON node) import Prism from "prismjs"; import "prismjs/components/prism-yaml"; import "prismjs/components/prism-bash"; import "prismjs/components/prism-powershell"; import "prismjs/components/prism-batch"; import "prismjs/themes/prism-okaidia.css"; // ---------- helpers ---------- const TYPE_OPTIONS = [ { key: "ansible", label: "Ansible Playbook", ext: ".yml", prism: "yaml" }, { key: "powershell", label: "Powershell Script", ext: ".ps1", prism: "powershell" }, { key: "batch", label: "Batch Script", ext: ".bat", prism: "batch" }, { key: "bash", label: "Bash Script", ext: ".sh", prism: "bash" } ]; const keyBy = (arr) => Object.fromEntries(arr.map((o) => [o.key, o])); const TYPES = keyBy(TYPE_OPTIONS); function typeFromFilename(name = "") { const n = name.toLowerCase(); if (n.endsWith(".yml")) return "ansible"; if (n.endsWith(".ps1")) return "powershell"; if (n.endsWith(".bat")) return "batch"; if (n.endsWith(".sh")) return "bash"; return "ansible"; // default } function ensureExt(baseName, typeKey) { const t = TYPES[typeKey] || TYPES.ansible; const bn = baseName.replace(/\.(yml|ps1|bat|sh)$/i, ""); return bn + t.ext; } function buildTree(scripts, folders) { const map = {}; const rootNode = { id: "root", label: "Scripts", path: "", isFolder: true, children: [] }; map[rootNode.id] = rootNode; (folders || []).forEach((f) => { const parts = (f || "").split("/"); let children = rootNode.children; let parentPath = ""; parts.forEach((part) => { const path = parentPath ? `${parentPath}/${part}` : part; let node = children.find((n) => n.id === path); if (!node) { node = { id: path, label: part, path, isFolder: true, children: [] }; children.push(node); map[path] = node; } children = node.children; parentPath = path; }); }); (scripts || []).forEach((s) => { const parts = (s.rel_path || "").split("/"); let children = rootNode.children; let parentPath = ""; parts.forEach((part, idx) => { const path = parentPath ? `${parentPath}/${part}` : part; const isFile = idx === parts.length - 1; let node = children.find((n) => n.id === path); if (!node) { node = { id: path, label: isFile ? s.file_name : part, path, isFolder: !isFile, fileName: s.file_name, script: isFile ? s : null, children: [] }; children.push(node); map[path] = node; } if (!isFile) { children = node.children; parentPath = path; } }); }); return { root: [rootNode], map }; } function highlightedHtml(code, prismLang) { try { const grammar = Prism.languages[prismLang] || Prism.languages.markup; return Prism.highlight(code ?? "", grammar, prismLang); } catch { return (code ?? "").replace(/[&<>]/g, (c) => ({"&":"&","<":"<",">":">"}[c])); } } // Local dialog: rename script file function RenameScriptDialog({ open, value, onChange, onCancel, onSave }) { return ( (
)} sx={{ display: open ? "block" : "none" }} >
Rename Script onChange(e.target.value)} sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} />
); } // Local dialog: new script (name + type) function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate }) { return (
} sx={{ display: open ? "block" : "none" }}>
New Script onChangeName(e.target.value)} sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} /> Type
); } export default function ScriptEditor() { // Tree state const [tree, setTree] = useState([]); const [nodeMap, setNodeMap] = useState({}); const [contextMenu, setContextMenu] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const apiRef = useTreeViewApiRef(); const [dragNode, setDragNode] = useState(null); // Editor state const [currentPath, setCurrentPath] = useState(""); const [currentFolder, setCurrentFolder] = useState(""); const [fileName, setFileName] = useState(""); const [type, setType] = useState("ansible"); const [code, setCode] = useState(""); // Dialog state const [renameOpen, setRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(""); const [renameFolderOpen, setRenameFolderOpen] = useState(false); const [folderDialogMode, setFolderDialogMode] = useState("rename"); const [newScriptOpen, setNewScriptOpen] = useState(false); const [newScriptName, setNewScriptName] = useState(""); const [newScriptType, setNewScriptType] = useState("ansible"); const [deleteOpen, setDeleteOpen] = useState(false); const prismLang = useMemo(() => (TYPES[type]?.prism || "yaml"), [type]); const loadTree = useCallback(async () => { try { const resp = await fetch("/api/scripts/list"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); const { root, map } = buildTree(data.scripts || [], data.folders || []); setTree(root); setNodeMap(map); } catch (err) { console.error("Failed to load scripts:", err); setTree([]); setNodeMap({}); } }, []); useEffect(() => { loadTree(); }, [loadTree]); // Context menu const handleContextMenu = (e, node) => { e.preventDefault(); setSelectedNode(node); setContextMenu( contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null ); }; const handleDrop = async (target) => { if (!dragNode || !target.isFolder) return; // Prevent dropping into itself or its descendants if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) { setDragNode(null); return; } const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName; try { await fetch("/api/scripts/move_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: dragNode.path, new_path: newPath }) }); loadTree(); } catch (err) { console.error("Failed to move:", err); } setDragNode(null); }; const handleNodeSelect = async (_e, itemId) => { const node = nodeMap[itemId]; if (node && !node.isFolder) { try { const resp = await fetch(`/api/scripts/load?path=${encodeURIComponent(node.path)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); const folder = node.path.split("/").slice(0, -1).join("/"); setCurrentFolder(folder); setCurrentPath(node.path); setFileName(data.file_name || node.fileName || node.label); setType(typeFromFilename(data.file_name || node.fileName || node.label)); setCode(data.content || ""); } catch (err) { console.error("Failed to load script:", err); } } }; const saveScript = async () => { if (!currentPath && !fileName) { // prompt for name/type first if user started typing without creating setNewScriptName(""); setNewScriptType(type); setNewScriptOpen(true); return; } const payload = { path: currentPath || undefined, name: currentPath ? undefined : fileName, content: code, type }; try { const resp = await fetch("/api/scripts/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = await resp.json(); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); // Update state with normalized rel_path (may include ext changes) if (data.rel_path) { setCurrentPath(data.rel_path); const fname = data.rel_path.split("/").pop(); setFileName(fname); setType(typeFromFilename(fname)); // ensure tree refresh loadTree(); } } catch (err) { console.error("Failed to save script:", err); } }; const handleRename = () => { setContextMenu(null); if (!selectedNode) return; setRenameValue(selectedNode.label); if (selectedNode.isFolder) { setFolderDialogMode("rename"); setRenameFolderOpen(true); } else setRenameOpen(true); }; const handleNewFolder = () => { if (!selectedNode) return; setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }; const handleNewScript = () => { if (!selectedNode) return; setContextMenu(null); setNewScriptName(""); setNewScriptType("ansible"); setNewScriptOpen(true); }; const saveRenameFile = async () => { try { const res = await fetch("/api/scripts/rename_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: selectedNode.path, new_name: renameValue, type }) }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`); if (currentPath === selectedNode.path) { setCurrentPath(data.rel_path || selectedNode.path); const fname = (data.rel_path || selectedNode.path).split("/").pop(); setFileName(fname); setType(typeFromFilename(fname)); } setRenameOpen(false); loadTree(); } catch (err) { console.error("Failed to rename file:", err); setRenameOpen(false); } }; const saveRenameFolder = async () => { try { if (folderDialogMode === "rename" && selectedNode) { await fetch("/api/scripts/rename_folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: selectedNode.path, new_name: renameValue }) }); } else { const basePath = selectedNode ? selectedNode.path : ""; const newPath = basePath ? `${basePath}/${renameValue}` : renameValue; await fetch("/api/scripts/create_folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: newPath }) }); } setRenameFolderOpen(false); loadTree(); } catch (err) { console.error("Folder operation failed:", err); setRenameFolderOpen(false); } }; const confirmDelete = async () => { if (!selectedNode) return; try { if (selectedNode.isFolder) { await fetch("/api/scripts/delete_folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: selectedNode.path }) }); } else { await fetch("/api/scripts/delete_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: selectedNode.path }) }); if (currentPath === selectedNode.path) { setCurrentPath(""); setFileName(""); setCode(""); } } setDeleteOpen(false); loadTree(); } catch (err) { console.error("Failed to delete:", err); setDeleteOpen(false); } }; const createNewScript = () => { const folder = selectedNode?.isFolder ? selectedNode.path : (selectedNode?.path?.split("/").slice(0, -1).join("/") || currentFolder || ""); const finalName = ensureExt(newScriptName || "script", newScriptType); const newPath = folder ? `${folder}/${finalName}` : finalName; setCurrentFolder(folder); setCurrentPath(newPath); setFileName(finalName); setType(newScriptType); setCode(""); setNewScriptOpen(false); // Note: not saved until user clicks Save }; const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; const highlighted = useMemo(() => highlightedHtml(code, prismLang), [code, prismLang]); const renderItems = (nodes) => (nodes || []).map((n) => ( !n.isFolder && setDragNode(n)} onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }} onDrop={(e) => { e.preventDefault(); handleDrop(n); }} onContextMenu={(e) => handleContextMenu(e, n)} > {n.isFolder ? ( ) : ( )} {n.label} } > {n.children && n.children.length > 0 ? renderItems(n.children) : null} )); return ( {/* Left: Tree */} Scripts Create, edit, and rearrange scripts in folders. { if (dragNode) e.preventDefault(); }} onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} > {renderItems(tree)} setContextMenu(null)} anchorReference="anchorPosition" anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} > {selectedNode?.isFolder && ( <> New Script New Subfolder {selectedNode.id !== "root" && ( Rename )} {selectedNode.id !== "root" && ( { setContextMenu(null); setDeleteOpen(true); }}>Delete )} )} {!selectedNode?.isFolder && ( <> { setContextMenu(null); handleNodeSelect(null, selectedNode.id); }}>Edit Rename { setContextMenu(null); setDeleteOpen(true); }}>Delete )} {/* Right: Editor */} Type {fileName && ( )} {/* Editor input */} setCode(e.target.value)} sx={{ flex: 0, mb: 2, "& .MuiOutlinedInput-root": { backgroundColor: "#121212", color: "#e6edf3", fontFamily: "monospace", fontSize: "0.9rem", lineHeight: 1.4, "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } } }} /> {/* Highlighted preview */} Preview (syntax highlighted)
            
          
{/* Dialogs */} setRenameOpen(false)} onSave={saveRenameFile} /> setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} /> setNewScriptOpen(false)} onCreate={createNewScript} /> setDeleteOpen(false)} onConfirm={confirmDelete} />
); }