diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 9c99db6..e41aa5a 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -231,29 +231,33 @@ export default function App() { [setTabs] ); - const handleSaveFlow = useCallback(async () => { - const tab = tabs.find((t) => t.id === activeTabId); - if (!tab) return; - const name = window.prompt("Enter workflow name", tab.tab_name || "workflow"); - if (!name) return; - const payload = { - name, - workflow: { - tab_name: tab.tab_name, - nodes: tab.nodes, - edges: tab.edges + const handleSaveFlow = useCallback( + async (name) => { + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab || !name) return; + const payload = { + path: tab.folderPath ? `${tab.folderPath}/${name}` : name, + workflow: { + tab_name: tab.tab_name, + nodes: tab.nodes, + edges: tab.edges + } + }; + try { + await fetch("/api/storage/save_workflow", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + setTabs((prev) => + prev.map((t) => (t.id === activeTabId ? { ...t, tab_name: name } : t)) + ); + } catch (err) { + console.error("Failed to save workflow:", err); } - }; - try { - await fetch("/api/storage/save_workflow", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - } catch (err) { - console.error("Failed to save workflow:", err); - } - }, [tabs, activeTabId]); + }, + [tabs, activeTabId] + ); const renderMainContent = () => { switch (currentPage) { @@ -266,9 +270,13 @@ export default function App() { case "workflows": return ( { + onOpenWorkflow={async (workflow, folderPath, name) => { const newId = "flow_" + Date.now(); if (workflow && workflow.rel_path) { + const folder = workflow.rel_path + .split("/") + .slice(0, -1) + .join("/"); try { const resp = await fetch( `/api/storage/load_workflow?path=${encodeURIComponent( @@ -283,17 +291,32 @@ export default function App() { tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], - edges: data.edges || [] + edges: data.edges || [], + folderPath: folder } ]); } catch (err) { console.error("Failed to load workflow:", err); setTabs([ - { id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [] } + { + id: newId, + tab_name: workflow?.name || "Workflow", + nodes: [], + edges: [], + folderPath: folder + } ]); } } else { - setTabs([{ id: newId, tab_name: `Flow`, nodes: [], edges: [] }]); + setTabs([ + { + id: newId, + tab_name: name || "Flow", + nodes: [], + edges: [], + folderPath: folderPath || "" + } + ]); } setActiveTabId(newId); setCurrentPage("workflow-editor"); @@ -316,6 +339,7 @@ export default function App() { handleOpenCloseAllDialog={() => setConfirmCloseOpen(true)} fileInputRef={fileInputRef} onFileInputChange={onFileInputChange} + currentTabName={tabs.find((t) => t.id === activeTabId)?.tab_name} /> - Folder Name + {title} + + + + + + ); +} + +export function NewWorkflowDialog({ open, value, onChange, onCancel, onCreate }) { + return ( + + New Workflow + + onChange(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + + + + + + + ); +} + +export function SaveWorkflowDialog({ open, value, onChange, onCancel, onSave }) { + return ( + + Save Workflow + + onChange(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + @@ -165,6 +239,21 @@ export function RenameFolderDialog({ open, value, onChange, onCancel, onSave }) ); } +export function ConfirmDeleteDialog({ open, message, onCancel, onConfirm }) { + return ( + + Confirm Delete + + {message} + + + + + + + ); +} + export function DeleteDeviceDialog({ open, onCancel, onConfirm }) { return ( diff --git a/Data/Server/WebUI/src/Node_Sidebar.jsx b/Data/Server/WebUI/src/Node_Sidebar.jsx index 9b0d04d..2eb20e3 100644 --- a/Data/Server/WebUI/src/Node_Sidebar.jsx +++ b/Data/Server/WebUI/src/Node_Sidebar.jsx @@ -21,6 +21,7 @@ import { ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon } from "@mui/icons-material"; +import { SaveWorkflowDialog } from "./Dialogs"; export default function NodeSidebar({ categorizedNodes, @@ -29,10 +30,13 @@ export default function NodeSidebar({ handleSaveFlow, handleOpenCloseAllDialog, fileInputRef, - onFileInputChange + onFileInputChange, + currentTabName }) { const [expandedCategory, setExpandedCategory] = useState(null); const [collapsed, setCollapsed] = useState(false); + const [saveOpen, setSaveOpen] = useState(false); + const [saveName, setSaveName] = useState(""); const handleAccordionChange = (category) => (_, isExpanded) => { setExpandedCategory(isExpanded ? category : null); @@ -79,7 +83,15 @@ export default function NodeSidebar({ - @@ -213,6 +225,16 @@ export default function NodeSidebar({ {collapsed ? : } + setSaveOpen(false)} + onSave={() => { + setSaveOpen(false); + handleSaveFlow(saveName); + }} + /> ); } diff --git a/Data/Server/WebUI/src/Workflow_List.jsx b/Data/Server/WebUI/src/Workflow_List.jsx index d778b6b..15091ec 100644 --- a/Data/Server/WebUI/src/Workflow_List.jsx +++ b/Data/Server/WebUI/src/Workflow_List.jsx @@ -1,26 +1,17 @@ import React, { useState, useEffect, useCallback } from "react"; -import { - Paper, - Box, - Typography, - Button, - IconButton, - Menu, - MenuItem -} from "@mui/material"; -import { - PlayCircle as PlayCircleIcon, - MoreVert as MoreVertIcon, - Folder as FolderIcon, - Description as DescriptionIcon, - CreateNewFolder as CreateNewFolderIcon -} from "@mui/icons-material"; +import { Paper, Box, Typography, Menu, MenuItem } from "@mui/material"; +import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; import { SimpleTreeView, TreeItem, useTreeViewApiRef } from "@mui/x-tree-view"; -import { RenameWorkflowDialog, RenameFolderDialog } from "./Dialogs"; +import { + RenameWorkflowDialog, + RenameFolderDialog, + NewWorkflowDialog, + ConfirmDeleteDialog +} from "./Dialogs"; function buildTree(workflows, folders) { const map = {}; @@ -88,11 +79,15 @@ function buildTree(workflows, folders) { export default function WorkflowList({ onOpenWorkflow }) { const [tree, setTree] = useState([]); const [nodeMap, setNodeMap] = useState({}); - const [menuAnchor, setMenuAnchor] = useState(null); + const [contextMenu, setContextMenu] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [renameValue, setRenameValue] = useState(""); const [renameOpen, setRenameOpen] = useState(false); const [renameFolderOpen, setRenameFolderOpen] = useState(false); + const [folderDialogMode, setFolderDialogMode] = useState("rename"); + const [newWorkflowOpen, setNewWorkflowOpen] = useState(false); + const [newWorkflowName, setNewWorkflowName] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); const apiRef = useTreeViewApiRef(); const [dragNode, setDragNode] = useState(null); @@ -136,50 +131,57 @@ export default function WorkflowList({ onOpenWorkflow }) { loadTree(); }, [loadTree]); - const openMenu = (e, node) => { - e.stopPropagation(); - setMenuAnchor(e.currentTarget); + const handleContextMenu = (e, node) => { + e.preventDefault(); setSelectedNode(node); + setContextMenu( + contextMenu === null + ? { + mouseX: e.clientX - 2, + mouseY: e.clientY - 4 + } + : null + ); }; - const closeMenu = () => setMenuAnchor(null); - const handleRename = () => { - closeMenu(); + setContextMenu(null); if (!selectedNode) return; setRenameValue(selectedNode.label); - if (selectedNode.isFolder) setRenameFolderOpen(true); - else setRenameOpen(true); + if (selectedNode.isFolder) { + setFolderDialogMode("rename"); + setRenameFolderOpen(true); + } else setRenameOpen(true); }; const handleEdit = () => { - closeMenu(); + setContextMenu(null); if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) { onOpenWorkflow(selectedNode.workflow); } }; - const handleDeleteWorkflow = async () => { - closeMenu(); + const handleDelete = () => { + setContextMenu(null); if (!selectedNode) return; - try { - await fetch("/api/storage/delete_workflow", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: selectedNode.path }) - }); - loadTree(); - } catch (err) { - console.error("Failed to delete workflow:", err); - } + setDeleteOpen(true); }; - const handleCreateFolder = () => { - closeMenu(); + const handleNewFolder = () => { + if (!selectedNode) return; + setContextMenu(null); + setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }; + const handleNewWorkflow = () => { + if (!selectedNode) return; + setContextMenu(null); + setNewWorkflowName(""); + setNewWorkflowOpen(true); + }; + const saveRenameWorkflow = async () => { if (!selectedNode) return; try { @@ -197,7 +199,7 @@ export default function WorkflowList({ onOpenWorkflow }) { const saveRenameFolder = async () => { try { - if (selectedNode && selectedNode.isFolder) { + if (folderDialogMode === "rename" && selectedNode) { await fetch("/api/storage/rename_folder", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -226,6 +228,29 @@ export default function WorkflowList({ onOpenWorkflow }) { } }; + const confirmDelete = async () => { + if (!selectedNode) return; + try { + if (selectedNode.isFolder) { + await fetch("/api/storage/delete_folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedNode.path }) + }); + } else { + await fetch("/api/storage/delete_workflow", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedNode.path }) + }); + } + loadTree(); + } catch (err) { + console.error("Failed to delete:", err); + } + setDeleteOpen(false); + }; + const renderItems = (nodes) => nodes.map((n) => ( handleContextMenu(e, n)} > {n.isFolder ? ( @@ -250,13 +276,6 @@ export default function WorkflowList({ onOpenWorkflow }) { )} {n.label} - openMenu(e, n)} - sx={{ color: "#ccc" }} - > - - } > @@ -283,41 +302,7 @@ export default function WorkflowList({ onOpenWorkflow }) { Manage workflow folders and files. - - - - + setContextMenu(null)} + anchorReference="anchorPosition" + anchorPosition= + {contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} > {selectedNode?.isFolder && ( - New Folder - )} - {selectedNode && selectedNode.id !== "root" && ( - Rename + <> + New Workflow + New Subfolder + {selectedNode.id !== "root" && ( + Rename + )} + {selectedNode.id !== "root" && ( + Delete + )} + )} {!selectedNode?.isFolder && ( <> Edit - Delete + Rename + Delete )} @@ -370,6 +364,24 @@ export default function WorkflowList({ onOpenWorkflow }) { onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} + title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} + confirmText={folderDialogMode === "rename" ? "Save" : "Create"} + /> + setNewWorkflowOpen(false)} + onCreate={() => { + setNewWorkflowOpen(false); + onOpenWorkflow && onOpenWorkflow(null, selectedNode.path, newWorkflowName); + }} + /> + setDeleteOpen(false)} + onConfirm={confirmDelete} /> ); diff --git a/Data/Server/server.py b/Data/Server/server.py index 81474e0..56ec5da 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -127,6 +127,22 @@ def delete_workflow(): return jsonify({"error": str(e)}), 500 +@app.route("/api/storage/delete_folder", methods=["POST"]) +def delete_folder(): + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip() + workflows_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "Workflows") + ) + abs_path = os.path.abspath(os.path.join(workflows_root, rel_path)) + if not abs_path.startswith(workflows_root) or not os.path.isdir(abs_path): + return jsonify({"error": "Folder not found"}), 404 + try: + shutil.rmtree(abs_path) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @app.route("/api/storage/create_folder", methods=["POST"]) def create_folder(): data = request.get_json(silent=True) or {} @@ -274,23 +290,36 @@ def load_workflow(): @app.route("/api/storage/save_workflow", methods=["POST"]) def save_workflow(): data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip() name = (data.get("name") or "").strip() workflow = data.get("workflow") - if not name or not isinstance(workflow, dict): + if not isinstance(workflow, dict): return jsonify({"error": "Invalid payload"}), 400 - if not name.lower().endswith(".json"): - name += ".json" workflows_root = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "Workflows") ) os.makedirs(workflows_root, exist_ok=True) - safe_name = os.path.basename(name) - abs_path = os.path.join(workflows_root, safe_name) + + if rel_path: + if not rel_path.lower().endswith(".json"): + rel_path += ".json" + abs_path = os.path.abspath(os.path.join(workflows_root, rel_path)) + else: + if not name: + return jsonify({"error": "Invalid payload"}), 400 + if not name.lower().endswith(".json"): + name += ".json" + abs_path = os.path.abspath(os.path.join(workflows_root, os.path.basename(name))) + + if not abs_path.startswith(workflows_root): + return jsonify({"error": "Invalid path"}), 400 + + os.makedirs(os.path.dirname(abs_path), exist_ok=True) try: with open(abs_path, "w", encoding="utf-8") as fh: json.dump(workflow, fh, indent=2) - return jsonify({"status": "ok", "file_name": safe_name}) + return jsonify({"status": "ok"}) except Exception as e: return jsonify({"error": str(e)}), 500