feat: replace workflow ellipsis with context menu

This commit is contained in:
2025-08-10 02:23:31 -06:00
parent 7ad3602969
commit ef1098f5d7
5 changed files with 304 additions and 128 deletions

View File

@@ -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 (
<WorkflowList
onOpenWorkflow={async (workflow) => {
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}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs

View File

@@ -132,10 +132,18 @@ export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }
);
}
export function RenameFolderDialog({ open, value, onChange, onCancel, onSave }) {
export function RenameFolderDialog({
open,
value,
onChange,
onCancel,
onSave,
title = "Folder Name",
confirmText = "Save"
}) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Folder Name</DialogTitle>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<TextField
autoFocus
@@ -157,6 +165,72 @@ export function RenameFolderDialog({ open, value, onChange, onCancel, onSave })
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>{confirmText}</Button>
</DialogActions>
</Dialog>
);
}
export function NewWorkflowDialog({ open, value, onChange, onCancel, onCreate }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>New Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</DialogActions>
</Dialog>
);
}
export function SaveWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Save Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
@@ -165,6 +239,21 @@ export function RenameFolderDialog({ open, value, onChange, onCancel, onSave })
);
}
export function ConfirmDeleteDialog({ open, message, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>{message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onConfirm} sx={{ color: "#58a6ff" }}>Confirm</Button>
</DialogActions>
</Dialog>
);
}
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>

View File

@@ -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({
</Button>
</Tooltip>
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
<Button fullWidth startIcon={<SaveIcon />} onClick={handleSaveFlow} sx={buttonStyle}>
<Button
fullWidth
startIcon={<SaveIcon />}
onClick={() => {
setSaveName(currentTabName || "workflow");
setSaveOpen(true);
}}
sx={buttonStyle}
>
Save Workflow
</Button>
</Tooltip>
@@ -213,6 +225,16 @@ export default function NodeSidebar({
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Box>
</Tooltip>
<SaveWorkflowDialog
open={saveOpen}
value={saveName}
onChange={setSaveName}
onCancel={() => setSaveOpen(false)}
onSave={() => {
setSaveOpen(false);
handleSaveFlow(saveName);
}}
/>
</div>
);
}

View File

@@ -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) => (
<TreeItem
@@ -243,6 +268,7 @@ export default function WorkflowList({ onOpenWorkflow }) {
e.preventDefault();
handleDrop(n);
}}
onContextMenu={(e) => handleContextMenu(e, n)}
>
{n.isFolder ? (
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
@@ -250,13 +276,6 @@ export default function WorkflowList({ onOpenWorkflow }) {
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
<IconButton
size="small"
onClick={(e) => openMenu(e, n)}
sx={{ color: "#ccc" }}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
}
>
@@ -283,41 +302,7 @@ export default function WorkflowList({ onOpenWorkflow }) {
Manage workflow folders and files.
</Typography>
</Box>
<Box>
<Button
startIcon={<CreateNewFolderIcon />}
sx={{
mr: 1,
color: "#0475c2",
borderColor: "#0475c2",
textTransform: "none",
border: "1px solid #0475c2",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
onClick={() => {
setSelectedNode(null);
setRenameValue("");
setRenameFolderOpen(true);
}}
>
New Folder
</Button>
<Button
startIcon={<PlayCircleIcon />}
sx={{
color: "#0475c2",
borderColor: "#0475c2",
textTransform: "none",
border: "1px solid #0475c2",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
onClick={() => onOpenWorkflow && onOpenWorkflow()}
>
New Workflow
</Button>
</Box>
<Box />
</Box>
<Box
sx={{ p: 2 }}
@@ -339,21 +324,30 @@ export default function WorkflowList({ onOpenWorkflow }) {
</SimpleTreeView>
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={closeMenu}
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition=
{contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
{selectedNode?.isFolder && (
<MenuItem onClick={handleCreateFolder}>New Folder</MenuItem>
)}
{selectedNode && selectedNode.id !== "root" && (
<MenuItem onClick={handleRename}>Rename</MenuItem>
<>
<MenuItem onClick={handleNewWorkflow}>New Workflow</MenuItem>
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
{selectedNode.id !== "root" && (
<MenuItem onClick={handleRename}>Rename</MenuItem>
)}
{selectedNode.id !== "root" && (
<MenuItem onClick={handleDelete}>Delete</MenuItem>
)}
</>
)}
{!selectedNode?.isFolder && (
<>
<MenuItem onClick={handleEdit}>Edit</MenuItem>
<MenuItem onClick={handleDeleteWorkflow}>Delete</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDelete}>Delete</MenuItem>
</>
)}
</Menu>
@@ -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"}
/>
<NewWorkflowDialog
open={newWorkflowOpen}
value={newWorkflowName}
onChange={setNewWorkflowName}
onCancel={() => setNewWorkflowOpen(false)}
onCreate={() => {
setNewWorkflowOpen(false);
onOpenWorkflow && onOpenWorkflow(null, selectedNode.path, newWorkflowName);
}}
/>
<ConfirmDeleteDialog
open={deleteOpen}
message="If you delete this, there is no undo button, are you sure you want to proceed?"
onCancel={() => setDeleteOpen(false)}
onConfirm={confirmDelete}
/>
</Paper>
);

View File

@@ -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