Mass-Restructure of JSX Folder Structure

This commit is contained in:
2025-09-02 20:07:29 -06:00
parent 0f412f7491
commit 4bbef112ec
11 changed files with 11 additions and 12 deletions

View File

@@ -0,0 +1,391 @@
import React, { useState, useEffect, useCallback } from "react";
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,
NewWorkflowDialog,
ConfirmDeleteDialog
} from "../Dialogs";
function buildTree(workflows, folders) {
const map = {};
const rootNode = {
id: "root",
label: "Workflows",
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;
});
});
(workflows || []).forEach((w) => {
const parts = (w.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
? w.tab_name && w.tab_name.trim().length > 0
? w.tab_name.trim()
: w.file_name
: part,
path,
isFolder: !isFile,
fileName: w.file_name,
workflow: isFile ? w : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
export default function WorkflowList({ onOpenWorkflow }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
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);
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/storage/move_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
});
loadTree();
} catch (err) {
console.error("Failed to move workflow:", err);
}
setDragNode(null);
};
const loadTree = useCallback(async () => {
try {
const resp = await fetch("/api/storage/load_workflows");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.workflows || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load workflows:", err);
setTree([]);
setNodeMap({});
}
}, []);
useEffect(() => {
loadTree();
}, [loadTree]);
const handleContextMenu = (e, node) => {
e.preventDefault();
setSelectedNode(node);
setContextMenu(
contextMenu === null
? {
mouseX: e.clientX - 2,
mouseY: e.clientY - 4
}
: null
);
};
const handleRename = () => {
setContextMenu(null);
if (!selectedNode) return;
setRenameValue(selectedNode.label);
if (selectedNode.isFolder) {
setFolderDialogMode("rename");
setRenameFolderOpen(true);
} else setRenameOpen(true);
};
const handleEdit = () => {
setContextMenu(null);
if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) {
onOpenWorkflow(selectedNode.workflow);
}
};
const handleDelete = () => {
setContextMenu(null);
if (!selectedNode) return;
setDeleteOpen(true);
};
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 {
await fetch("/api/storage/rename_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path, new_name: renameValue })
});
loadTree();
} catch (err) {
console.error("Failed to rename workflow:", err);
}
setRenameOpen(false);
};
const saveRenameFolder = async () => {
try {
if (folderDialogMode === "rename" && selectedNode) {
await fetch("/api/storage/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/storage/create_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: newPath })
});
}
loadTree();
} catch (err) {
console.error("Folder operation failed:", err);
}
setRenameFolderOpen(false);
};
const handleNodeSelect = (event, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder && onOpenWorkflow) {
onOpenWorkflow(node.workflow);
}
};
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
key={n.id}
itemId={n.id}
label={
<Box
sx={{ display: "flex", alignItems: "center" }}
draggable={!n.isFolder}
onDragStart={() => !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 ? (
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
) : (
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
</TreeItem>
));
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box
sx={{
p: 2,
pb: 1,
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Workflows
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Create, edit, and rearrange workflows within an organized folder structure.
</Typography>
</Box>
<Box />
</Box>
<Box
sx={{ p: 2 }}
onDragOver={(e) => {
if (dragNode) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
handleDrop({ path: "", isFolder: true });
}}
>
<SimpleTreeView
key={rootChildIds.join(",")}
sx={{ color: "#e6edf3" }}
onNodeSelect={handleNodeSelect}
apiRef={apiRef}
defaultExpandedItems={["root", ...rootChildIds]}
>
{renderItems(tree)}
</SimpleTreeView>
</Box>
<Menu
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={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={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDelete}>Delete</MenuItem>
</>
)}
</Menu>
<RenameWorkflowDialog
open={renameOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={saveRenameWorkflow}
/>
<RenameFolderDialog
open={renameFolderOpen}
value={renameValue}
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>
);
}