diff --git a/Data/WebUI/src/App.jsx b/Data/WebUI/src/App.jsx
index 6209cf7..dc707b4 100644
--- a/Data/WebUI/src/App.jsx
+++ b/Data/WebUI/src/App.jsx
@@ -1,5 +1,10 @@
// Core React Imports
-import React, { useState, useEffect, useCallback, useRef } from "react";
+import React, {
+ useState,
+ useEffect,
+ useCallback,
+ useRef
+} from "react";
// Material UI - Components
import {
@@ -22,7 +27,10 @@ import {
DialogContentText,
DialogActions,
Divider,
- Tooltip
+ Tooltip,
+ Tabs,
+ Tab,
+ TextField
} from "@mui/material";
// Material UI - Icons
@@ -36,7 +44,8 @@ import {
InfoOutlined as InfoOutlinedIcon,
Polyline as PolylineIcon,
MergeType as MergeTypeIcon,
- People as PeopleIcon
+ People as PeopleIcon,
+ Add as AddIcon
} from "@mui/icons-material";
// React Flow
@@ -55,10 +64,11 @@ import "./Borealis.css";
// Global Node Update Timer Variable
if (!window.BorealisUpdateRate) {
- window.BorealisUpdateRate = 200; // Default Update Rate: 100ms
+ window.BorealisUpdateRate = 200;
}
-const nodeContext = require.context("./nodes", true, /\.jsx$/); // Dynamically import all node components from the nodes directory
+// Dynamically load all node components
+const nodeContext = require.context("./nodes", true, /\.jsx$/);
const nodeTypes = {};
const categorizedNodes = {};
@@ -75,14 +85,15 @@ nodeContext.keys().forEach((path) => {
if (!categorizedNodes[category]) {
categorizedNodes[category] = [];
}
- categorizedNodes[category].push(mod.default); // includes type, label, component, defaultContent
+ categorizedNodes[category].push(mod.default);
nodeTypes[type] = component;
});
+// Single flow editor
function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
- const reactFlowWrapper = useRef(null);
+ const wrapperRef = useRef(null);
const { project } = useReactFlow();
- const [contextMenu, setContextMenu] = useState(null); // Node Right-Click Context Menu
+ const [contextMenu, setContextMenu] = useState(null);
const onDrop = useCallback(
(event) => {
@@ -90,22 +101,22 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
- const bounds = reactFlowWrapper.current.getBoundingClientRect();
+ const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
- const id = `node-${Date.now()}`;
+ const id = "node-" + Date.now();
const nodeMeta = Object.values(categorizedNodes)
.flat()
.find((n) => n.type === type);
const newNode = {
- id,
- type,
- position,
+ id: id,
+ type: type,
+ position: position,
data: {
label: nodeMeta?.label || type,
content: nodeMeta?.content
@@ -151,11 +162,11 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
[setEdges]
);
- const handleRightClick = (event, node) => {
- event.preventDefault();
+ const handleRightClick = (e, node) => {
+ e.preventDefault();
setContextMenu({
- mouseX: event.clientX + 2,
- mouseY: event.clientY - 6,
+ mouseX: e.clientX + 2,
+ mouseY: e.clientY - 6,
nodeId: node.id
});
};
@@ -163,7 +174,11 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
const handleDisconnect = () => {
if (contextMenu?.nodeId) {
setEdges((eds) =>
- eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId)
+ eds.filter(
+ (e) =>
+ e.source !== contextMenu.nodeId &&
+ e.target !== contextMenu.nodeId
+ )
);
}
setContextMenu(null);
@@ -171,9 +186,15 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
const handleRemoveNode = () => {
if (contextMenu?.nodeId) {
- setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
+ setNodes((nds) =>
+ nds.filter((n) => n.id !== contextMenu.nodeId)
+ );
setEdges((eds) =>
- eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId)
+ eds.filter(
+ (e) =>
+ e.source !== contextMenu.nodeId &&
+ e.target !== contextMenu.nodeId
+ )
);
}
setContextMenu(null);
@@ -187,9 +208,8 @@ function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
}, [nodes]);
return (
-
+
- {/* Right-Click Node Menu */}
+ {/* Right-click node menu */}
-
);
}
-
const darkTheme = createTheme({
palette: {
mode: "dark",
@@ -264,41 +283,188 @@ const darkTheme = createTheme({
});
export default function App() {
+ const [tabs, setTabs] = useState([
+ {
+ id: "flow_1",
+ tab_name: "Flow 1",
+ nodes: [],
+ edges: []
+ }
+ ]);
+ const [activeTabId, setActiveTabId] = useState("flow_1");
+
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
- const [nodes, setNodes] = useState([]);
- const [edges, setEdges] = useState([]);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const fileInputRef = useRef(null);
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [renameTabId, setRenameTabId] = useState(null);
+ const [renameValue, setRenameValue] = useState("");
+
+ const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
+ const [tabMenuTabId, setTabMenuTabId] = useState(null);
+
+ // Update nodes/edges in a particular tab
+ const handleSetNodes = useCallback(
+ (callbackOrArray, tId) => {
+ const targetId = tId || activeTabId;
+ setTabs((old) =>
+ old.map((tab) => {
+ if (tab.id !== targetId) return tab;
+ const newNodes =
+ typeof callbackOrArray === "function"
+ ? callbackOrArray(tab.nodes)
+ : callbackOrArray;
+ return { ...tab, nodes: newNodes };
+ })
+ );
+ },
+ [activeTabId]
+ );
+
+ const handleSetEdges = useCallback(
+ (callbackOrArray, tId) => {
+ const targetId = tId || activeTabId;
+ setTabs((old) =>
+ old.map((tab) => {
+ if (tab.id !== targetId) return tab;
+ const newEdges =
+ typeof callbackOrArray === "function"
+ ? callbackOrArray(tab.edges)
+ : callbackOrArray;
+ return { ...tab, edges: newEdges };
+ })
+ );
+ },
+ [activeTabId]
+ );
+
+ // About menu
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
- const handleOpenCloseDialog = () => setConfirmCloseOpen(true);
+
+ // Close all flows
+ const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
const handleCloseDialog = () => setConfirmCloseOpen(false);
- const handleConfirmCloseWorkflow = () => {
- setNodes([]);
- setEdges([]);
+ const handleConfirmCloseAll = () => {
+ setTabs([
+ {
+ id: "flow_1",
+ tab_name: "Flow 1",
+ nodes: [],
+ edges: []
+ }
+ ]);
+ setActiveTabId("flow_1");
setConfirmCloseOpen(false);
};
- const handleSaveWorkflow = async () => {
- const data = JSON.stringify({ nodes, edges }, null, 2);
+ // Create new tab
+ const createNewTab = () => {
+ const nextIndex = tabs.length + 1;
+ const newId = "flow_" + nextIndex;
+ setTabs((old) => [
+ ...old,
+ {
+ id: newId,
+ tab_name: "Flow " + nextIndex,
+ nodes: [],
+ edges: []
+ }
+ ]);
+ setActiveTabId(newId);
+ };
+
+ // Right-click tab menu
+ const handleTabRightClick = (evt, tabId) => {
+ evt.preventDefault();
+ setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
+ setTabMenuTabId(tabId);
+ };
+ const handleCloseTabMenu = () => {
+ setTabMenuAnchor(null);
+ setTabMenuTabId(null);
+ };
+
+ // Rename / close tab
+ const handleRenameTab = () => {
+ setRenameDialogOpen(true);
+ setRenameTabId(tabMenuTabId);
+ const t = tabs.find((x) => x.id === tabMenuTabId);
+ setRenameValue(t ? t.tab_name : "");
+ handleCloseTabMenu();
+ };
+ const handleCloseTab = () => {
+ setTabs((old) => {
+ const idx = old.findIndex((t) => t.id === tabMenuTabId);
+ if (idx === -1) return old;
+
+ const newList = [...old];
+ newList.splice(idx, 1);
+
+ if (tabMenuTabId === activeTabId && newList.length > 0) {
+ setActiveTabId(newList[0].id);
+ } else if (newList.length === 0) {
+ newList.push({
+ id: "flow_1",
+ tab_name: "Flow 1",
+ nodes: [],
+ edges: []
+ });
+ setActiveTabId("flow_1");
+ }
+ return newList;
+ });
+ handleCloseTabMenu();
+ };
+ const handleRenameDialogSave = () => {
+ if (!renameTabId) {
+ setRenameDialogOpen(false);
+ return;
+ }
+ setTabs((old) =>
+ old.map((tab) =>
+ tab.id === renameTabId
+ ? { ...tab, tab_name: renameValue }
+ : tab
+ )
+ );
+ setRenameDialogOpen(false);
+ };
+
+ // Export current tab
+ const handleExportFlow = async () => {
+ const activeTab = tabs.find((x) => x.id === activeTabId);
+ if (!activeTab) return;
+
+ const data = JSON.stringify(
+ {
+ nodes: activeTab.nodes,
+ edges: activeTab.edges,
+ tab_name: activeTab.tab_name
+ },
+ null,
+ 2
+ );
const blob = new Blob([data], { type: "application/json" });
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: "workflow.json",
- types: [{
- description: "Workflow JSON File",
- accept: { "application/json": [".json"] }
- }]
+ types: [
+ {
+ description: "Workflow JSON File",
+ accept: { "application/json": [".json"] }
+ }
+ ]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
- } catch (error) {
- console.error("Save cancelled or failed:", error);
+ } catch (err) {
+ console.error("Save cancelled or failed:", err);
}
} else {
const a = document.createElement("a");
@@ -312,174 +478,537 @@ export default function App() {
}
};
- const handleOpenWorkflow = async () => {
+ // Import flow -> new tab
+ const handleImportFlow = async () => {
if (window.showOpenFilePicker) {
try {
const [fileHandle] = await window.showOpenFilePicker({
- types: [{
- description: "Workflow JSON File",
- accept: { "application/json": [".json"] }
- }]
+ types: [
+ {
+ description: "Workflow JSON File",
+ accept: { "application/json": [".json"] }
+ }
+ ]
});
const file = await fileHandle.getFile();
const text = await file.text();
- const { nodes: loadedNodes, edges: loadedEdges } = JSON.parse(text);
- const confirm = window.confirm("Opening a workflow will overwrite your current one. Continue?");
- if (!confirm) return;
- setNodes(loadedNodes);
- setEdges(loadedEdges);
- } catch (error) {
- console.error("Open cancelled or failed:", error);
+ const json = JSON.parse(text);
+
+ const newId = "flow_" + (tabs.length + 1);
+ setTabs((prev) => [
+ ...prev,
+ {
+ id: newId,
+ tab_name:
+ json.tab_name ||
+ "Imported Flow " + (tabs.length + 1),
+ nodes: json.nodes || [],
+ edges: json.edges || []
+ }
+ ]);
+ setActiveTabId(newId);
+ } catch (err) {
+ console.error("Import cancelled or failed:", err);
}
} else {
fileInputRef.current?.click();
}
};
+ // Fallback
import
+ const handleFileInputChange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ try {
+ const text = await file.text();
+ const json = JSON.parse(text);
+
+ const newId = "flow_" + (tabs.length + 1);
+ setTabs((prev) => [
+ ...prev,
+ {
+ id: newId,
+ tab_name:
+ json.tab_name ||
+ "Imported Flow " + (tabs.length + 1),
+ nodes: json.nodes || [],
+ edges: json.edges || []
+ }
+ ]);
+ setActiveTabId(newId);
+ } catch (err) {
+ console.error("Failed to read file:", err);
+ }
+ };
+
+ /**
+ * Tab onChange logic:
+ * If user clicks the plus βtabβ, newValue = β__addtab__β.
+ * Otherwise, newValue is an index: setActiveTab accordingly.
+ */
+ const handleTabChange = (event, newValue) => {
+ if (newValue === "__addtab__") {
+ // Create the new tab
+ createNewTab();
+ } else {
+ // Normal tab index
+ setActiveTabId(tabs[newValue].id);
+ }
+ };
+
return (
-
-
-
- Borealis - Workflow Automation Tool
-
- }
- startIcon={}
- >
- About
-
-
-
-
-
-
-
-
-
- } sx={accordionHeaderStyle}>
- Workflows
+ }
+ sx={{
+ backgroundColor: "#2c2c2c",
+ minHeight: "36px",
+ "& .MuiAccordionSummary-content": {
+ margin: 0
+ }
+ }}
+ >
+
+ Workflows
+
- } sx={sidebarBtnStyle} onClick={handleSaveWorkflow}>
- SAVE WORKFLOW
+ }
+ sx={{
+ color: "#ccc",
+ backgroundColor: "#232323",
+ justifyContent: "flex-start",
+ pl: 2,
+ fontSize: "0.9rem",
+ textTransform: "none",
+ "&:hover": {
+ backgroundColor: "#2a2a2a"
+ }
+ }}
+ onClick={handleExportFlow}
+ >
+ Export Current Flow
- } sx={sidebarBtnStyle} onClick={handleOpenWorkflow}>
- OPEN WORKFLOW
+ }
+ sx={{
+ color: "#ccc",
+ backgroundColor: "#232323",
+ justifyContent: "flex-start",
+ pl: 2,
+ fontSize: "0.9rem",
+ textTransform: "none",
+ "&:hover": {
+ backgroundColor: "#2a2a2a"
+ }
+ }}
+ onClick={handleImportFlow}
+ >
+ Import Flow
- } sx={sidebarBtnStyle} onClick={handleOpenCloseDialog}>
- CLOSE WORKFLOW
+ }
+ sx={{
+ color: "#ccc",
+ backgroundColor: "#232323",
+ justifyContent: "flex-start",
+ pl: 2,
+ fontSize: "0.9rem",
+ textTransform: "none",
+ "&:hover": {
+ backgroundColor: "#2a2a2a"
+ }
+ }}
+ onClick={handleOpenCloseAllDialog}
+ >
+ Close All Flow Tabs
-
- } sx={accordionHeaderStyle}>
- Nodes
+
+ }
+ sx={{
+ backgroundColor: "#2c2c2c",
+ minHeight: "36px",
+ "& .MuiAccordionSummary-content": {
+ margin: 0
+ }
+ }}
+ >
+
+ Nodes
+
- {Object.entries(categorizedNodes).map(([category, items]) => (
-
- (
+
-
- {category}
-
-
- {items.map(({ type, label, description }) => (
-
- {description || "Drag & Drop into Editor"}
-
- }
- placement="right"
- arrow
- >
-
-
- ))}
-
- ))}
+ {category}
+
+
+ {items.map((nodeDef) => (
+
+ {nodeDef.description ||
+ "Drag & Drop into Editor"}
+
+ }
+ placement="right"
+ arrow
+ >
+
+
+ ))}
+
+ )
+ )}
-
-
-
-
+ {/* Right content area: tab bar plus flow editors */}
+
+ {/* Tab bar with special 'add tab' value */}
+
+ {
+ // Return the index of the active tab,
+ // or fallback to -1 if none
+ const idx = tabs.findIndex(
+ (t) => t.id === activeTabId
+ );
+ return idx >= 0 ? idx : 0;
+ })()}
+ onChange={handleTabChange}
+ variant="scrollable"
+ scrollButtons="auto"
+ textColor="inherit"
+ TabIndicatorProps={{
+ style: { backgroundColor: "#58a6ff" }
+ }}
+ sx={{
+ minHeight: "36px",
+ height: "36px",
+ flexGrow: 1
+ }}
+ >
+ {tabs.map((tab, index) => (
+
+ handleTabRightClick(evt, tab.id)
+ }
+ sx={{
+ minHeight: "36px",
+ height: "36px",
+ textTransform: "none",
+ backgroundColor:
+ tab.id === activeTabId
+ ? "#2C2C2C"
+ : "transparent",
+ color: "#58a6ff"
+ }}
+ />
+ ))}
+ {/* The 'plus' tab has a special value to detect in onChange */}
+ }
+ value="__addtab__"
+ sx={{
+ minHeight: "36px",
+ height: "36px",
+ color: "#58a6ff",
+ textTransform: "none"
+ }}
+ />
+
+
+
+ {/* The flow editors themselves */}
+
+ {tabs.map((tab) => (
+
+
+
+ handleSetNodes(val, tab.id)
+ }
+ setEdges={(val) =>
+ handleSetEdges(val, tab.id)
+ }
+ nodeTypes={nodeTypes}
+ />
+
+
+ ))}
+
-
+ {/* Bottom status bar */}
+
Nodes: 0
-
+
Update Rate (ms):
{
- const val = parseInt(document.getElementById("updateRateInput")?.value);
+ const val = parseInt(
+ document.getElementById("updateRateInput")
+ ?.value
+ );
if (!isNaN(val) && val >= 50) {
window.BorealisUpdateRate = val;
console.log("Global update rate set to", val + "ms");
} else {
- alert("Please enter a valid number (minimum 50)");
+ alert("Please enter a valid number (min 50).");
}
}}
- sx={{ color: "#58a6ff", borderColor: "#58a6ff", fontSize: "0.75rem", textTransform: "none", px: 1.5 }}
+ sx={{
+ color: "#58a6ff",
+ borderColor: "#58a6ff",
+ fontSize: "0.75rem",
+ textTransform: "none",
+ px: 1.5
+ }}
>
Apply Rate
-
);
}
-
-const sidebarBtnStyle = {
- color: "#ccc",
- backgroundColor: "#232323",
- justifyContent: "flex-start",
- pl: 2,
- fontSize: "0.9rem",
- textTransform: "none",
- "&:hover": {
- backgroundColor: "#2a2a2a"
- }
-};
-
-const accordionHeaderStyle = {
- backgroundColor: "#2c2c2c",
- minHeight: "36px",
- "& .MuiAccordionSummary-content": { margin: 0 }
-};
diff --git a/Data/WebUI/src/Borealis.css b/Data/WebUI/src/Borealis.css
index b1bec8a..45b4a8a 100644
--- a/Data/WebUI/src/Borealis.css
+++ b/Data/WebUI/src/Borealis.css
@@ -5,7 +5,6 @@
height: 100vh;
}
-
/* Blue Gradient Overlay */
.flow-editor-container::before {
content: "";
@@ -14,16 +13,17 @@
left: 0;
width: 100%;
height: 100%;
- pointer-events: none; /* Ensures grid and nodes remain fully interactive */
- background: linear-gradient( to bottom, rgba(9, 44, 68, 0.9) 0%, /* Deep blue at the top */
- rgba(30, 30, 30, 0) 45%, /* Fade out towards center */
- rgba(30, 30, 30, 0) 75%, /* No gradient in the middle */
- rgba(9, 44, 68, 0.7) 100% /* Deep blue at the bottom */
+ pointer-events: none;
+ background: linear-gradient(
+ to bottom,
+ rgba(9, 44, 68, 0.9) 0%,
+ rgba(30, 30, 30, 0) 45%,
+ rgba(30, 30, 30, 0) 75%,
+ rgba(9, 44, 68, 0.7) 100%
);
- z-index: -1; /* Ensures it stays behind the React Flow elements */
+ z-index: -1;
}
-
/* Emphasize Drag & Drop Node Functionality */
.sidebar-button:hover {
background-color: #2a2a2a !important;
@@ -31,7 +31,6 @@
cursor: grab;
}
-
/* Borealis Node Styling */
.borealis-node {
background: #2c2c2c;
@@ -42,7 +41,8 @@
min-width: 160px;
max-width: 260px;
position: relative;
- box-shadow: 0 0 5px rgba(88, 166, 255, 0.15), 0 0 10px rgba(88, 166, 255, 0.15);
+ box-shadow: 0 0 5px rgba(88, 166, 255, 0.15),
+ 0 0 10px rgba(88, 166, 255, 0.15);
transition: box-shadow 0.3s ease-in-out;
}
.borealis-node-header {
@@ -65,7 +65,9 @@
}
/* Global dark form inputs */
-input, select, button {
+input,
+select,
+button {
background-color: #2a2a2a;
color: #ccc;
border: 1px solid #444;
@@ -77,3 +79,17 @@ label {
color: #aaa;
font-size: 10px;
}
+
+/* Multi-Tab Bar Adjustments */
+.MuiTabs-root {
+ min-height: 32px !important;
+}
+
+.MuiTab-root {
+ min-height: 32px !important;
+ padding: 6px 12px !important;
+ color: #58a6ff !important;
+ text-transform: none !important;
+}
+
+/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */