+ ) : null}
)}
@@ -494,6 +1191,7 @@ const Dashboard: React.FC = () => {
role="button"
tabIndex={0}
onClick={() => handleOpenWorkspace(file.workspaceId)}
+ onContextMenu={(event) => handleOpenContextMenu(event, file.workspaceId)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -530,6 +1228,7 @@ const Dashboard: React.FC = () => {
handleOpenWorkspace(file.workspaceId)}
+ onContextMenu={(event) => handleOpenContextMenu(event, file.workspaceId)}
className="drive-dashboard__row-button"
>
|
@@ -547,10 +1246,482 @@ const Dashboard: React.FC = () => {
))}
+ )}
+
+
+
+ {contextMenu.visible &&
+ contextMenu.workspaceId &&
+ createPortal(
+ e.preventDefault()}
+ >
+
+
+
+
+ {activeTab === "trash" ? (
+
+ ) : (
+
+ )}
+ ,
+ document.body
+ )}
+
+ {isEditModalOpen && (
+ setIsEditModalOpen(false)}
+ >
+
+
+ )}
+
+ {folderContextMenu.visible && folderContextMenu.path && (
+
+
+
+
+
+
+
+ )}
+
+ {folderActionMenu.visible &&
+ createPortal(
+ e.preventDefault()}
+ >
+
+
+ ,
+ document.body
+ )}
+
+ {newActionMenu.visible &&
+ createPortal(
+ e.preventDefault()}
+ >
+
+
+ ,
+ document.body
+ )}
+
+ {typeFilterMenu.visible &&
+ createPortal(
+ e.preventDefault()}
+ >
+
+
+ ,
+ document.body
)}
-
-
-
+
+ {locationFilterMenu.visible &&
+ createPortal(
+ e.preventDefault()}
+ >
+
+
+ ,
+ document.body
+ )}
+
+ {pendingFolderDelete && (
+ setPendingFolderDelete(null)}
+ >
+ e.stopPropagation()}
+ >
+
+ Delete “{pendingFolderDelete.name}”?
+
+
+
+
+ All contents inside this folder will be moved to Trash, including nested folders
+ and workspaces.
+
+ {(pendingFolderDelete.childFolders > 0 || pendingFolderDelete.childWorkspaces > 0) && (
+
+ {pendingFolderDelete.childFolders > 0 && (
+ - {pendingFolderDelete.childFolders} subfolder(s)
+ )}
+ {pendingFolderDelete.childWorkspaces > 0 && (
+ - {pendingFolderDelete.childWorkspaces} workspace(s)
+ )}
+
+ )}
+
+
+
+
+ )}
+
+ {(isCreateFolderOpen || isCreateFolderClosing) && (
+
+ e.stopPropagation()}
+ >
+
+ {createFolderMode === "rename" ? "Rename folder" : "Create folder"}
+
+
+
+
+
+
+
+
+ )}
+
);
};
diff --git a/apps/desktop-app/src/renderer/pages/Workspace.tsx b/apps/desktop-app/src/renderer/pages/Workspace.tsx
index 3919d25..8f4815a 100644
--- a/apps/desktop-app/src/renderer/pages/Workspace.tsx
+++ b/apps/desktop-app/src/renderer/pages/Workspace.tsx
@@ -1,27 +1,41 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ ReactFlowProvider,
+ ReactFlow,
+ Background,
+ Controls,
+ MiniMap,
+ addEdge,
+ useEdgesState,
+ useNodesState,
+ MarkerType,
+ Handle,
+ Position,
+ type ReactFlowInstance,
+ type Connection,
+ type Edge,
+ type Node,
+ type NodeProps,
+ type OnSelectionChangeFunc,
+ type CoordinateExtent,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
import type { IconType } from "react-icons";
import {
- FiCamera,
- FiCpu,
+ FiChevronLeft,
FiCommand,
FiDatabase,
+ FiEdit2,
FiMap,
- FiTarget,
- FiZap,
FiPlay,
- FiChevronLeft,
FiSave,
FiTrash2,
- FiEdit2,
+ FiSun,
+ FiMoon,
} from "react-icons/fi";
import { useNavigate, useParams } from "react-router-dom";
import type { WorkspaceDocument, WorkspaceNode } from "../../shared/workspace";
+import { runtime } from "../runtime/registry";
import "../styles/Workspace.css";
type PaletteItem = {
@@ -33,113 +47,129 @@ type PaletteItem = {
defaultMeta?: WorkspaceNode["meta"];
};
+type RosNodeData = {
+ label: string;
+ type: string;
+ color: string;
+ meta?: WorkspaceNode["meta"];
+};
+
+type FlowNode = Node;
+type WorkspaceMeta = (WorkspaceDocument["meta"] & { edges?: Edge[] }) | undefined;
+
const randomNodeId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10);
+// Limit how far the viewport and nodes can move so the user stays on the grid.
+const WORKSPACE_EXTENT: CoordinateExtent = [
+ [0, 0],
+ [2400, 1600],
+];
+
const nodeTypeColors: Record = {
- entry: "#38bdf8",
- sensor: "#0ea5e9",
- logic: "#f97316",
- actuator: "#facc15",
- transform: "#6366f1",
- model: "#8b5cf6",
- visualize: "#ec4899",
- map: "#22d3ee",
- costmap: "#14b8a6",
- planner: "#34d399",
- goal: "#f87171",
- io: "#f472b6",
- compute: "#c084fc",
+ ArrowKeyPub: "#38bdf8",
+ ConsoleSub: "#f97316",
+ RosbridgeBridge: "#c084fc",
+ Forwarder: "#22d3ee",
default: "#a3a3a3",
};
const paletteGroups: Array<{ title: string; items: PaletteItem[] }> = [
{
- title: "Sensors & Inputs",
+ title: "Runtime Nodes",
items: [
{
- id: "camera-feed",
- label: "Camera Feed",
- type: "sensor",
- icon: FiCamera,
- description: "Capture RGB frames from your vision rig.",
- },
- {
- id: "lidar",
- label: "LiDAR Sweep",
- type: "sensor",
- icon: FiTarget,
- description: "360° point cloud from the LiDAR array.",
- },
- {
- id: "map-loader",
- label: "Map Loader",
- type: "map",
- icon: FiMap,
- description: "Bring in saved maps or SLAM outputs.",
- },
- ],
- },
- {
- title: "Processing",
- items: [
- {
- id: "preprocess",
- label: "Preprocess",
- type: "transform",
- icon: FiCpu,
- description: "Normalize, crop, and clean sensor data.",
- },
- {
- id: "detector",
- label: "Object Detector",
- type: "model",
+ id: "ArrowKeyPub",
+ label: "Arrow Keys Publisher",
+ type: "ArrowKeyPub",
icon: FiCommand,
- description: "Run inference with your trained model.",
+ description: "Publishes arrow key presses onto the workspace bus.",
+ defaultMeta: { topic: "keys/arrows" },
},
{
- id: "fusion",
- label: "Sensor Fusion",
- type: "logic",
+ id: "ConsoleSub",
+ label: "Console Subscriber",
+ type: "ConsoleSub",
icon: FiDatabase,
- description: "Combine inputs into a unified state.",
+ description: "Logs inbound messages from a topic to the console.",
+ defaultMeta: { topic: "keys/arrows" },
},
- ],
- },
- {
- title: "Control & Outputs",
- items: [
{
- id: "planner",
- label: "Route Planner",
- type: "planner",
+ id: "RosbridgeBridge",
+ label: "Rosbridge Bridge",
+ type: "RosbridgeBridge",
icon: FiMap,
- description: "Plan waypoints with costmaps and goals.",
+ description: "Connects to rosbridge and mirrors ROS publish/subscribe traffic.",
+ defaultMeta: { urls: ["ws://localhost:9090", "ws://127.0.0.1:9090"], retryMs: 2500 },
},
{
- id: "goal-dispatch",
- label: "Goal Dispatch",
- type: "goal",
+ id: "Forwarder",
+ label: "ROS Forwarder",
+ type: "Forwarder",
icon: FiPlay,
- description: "Send tasks to actuators or fleets.",
- },
- {
- id: "actuator",
- label: "Motor Driver",
- type: "actuator",
- icon: FiZap,
- description: "Drive motors or servos with commands.",
+ description: "Forwards workspace bus messages into ROS topics.",
+ defaultMeta: { from: "keys/arrows", to: "/keys/arrows" },
},
],
},
];
-type DragState = {
- id: string;
- offsetX: number;
- offsetY: number;
+const normalizePosition = (position?: WorkspaceNode["position"]): WorkspaceNode["position"] => {
+ if (!position || Number.isNaN(position.x) || Number.isNaN(position.y)) {
+ return { x: 140, y: 120 };
+ }
+ return {
+ x: Math.min(WORKSPACE_EXTENT[1][0], Math.max(WORKSPACE_EXTENT[0][0], position.x)),
+ y: Math.min(WORKSPACE_EXTENT[1][1], Math.max(WORKSPACE_EXTENT[0][1], position.y)),
+ };
};
+const toFlowNode = (node: WorkspaceNode): FlowNode => ({
+ id: node.id,
+ type: "rosNode",
+ position: normalizePosition(node.position),
+ data: {
+ label: node.label || node.type,
+ type: node.type,
+ color: nodeTypeColors[node.type] ?? nodeTypeColors.default,
+ meta: node.meta ?? {},
+ },
+});
+
+const toWorkspaceNode = (node: FlowNode): WorkspaceNode => ({
+ id: node.id,
+ type: node.data?.type ?? "Node",
+ label: node.data?.label ?? node.data?.type ?? "Node",
+ position: node.position,
+ meta: node.data?.meta ?? {},
+});
+
+const RosNode: React.FC> = ({ data, selected }) => (
+
+
+
+ {data.label}
+ {data.type}
+
+
+
+);
+
+const rosNodeTypes = { rosNode: RosNode };
+
const WorkspacePage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -148,25 +178,69 @@ const WorkspacePage: React.FC = () => {
const [error, setError] = useState(null);
const [workspaceDoc, setWorkspaceDoc] = useState(null);
const [workspaceName, setWorkspaceName] = useState("");
- const [workspaceMeta, setWorkspaceMeta] = useState(undefined);
- const [nodes, setNodes] = useState([]);
+ const [workspaceMeta, setWorkspaceMeta] = useState();
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedNodeId, setSelectedNodeId] = useState(null);
const [savingState, setSavingState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [saveTimestamp, setSaveTimestamp] = useState(null);
- const [zoom, setZoom] = useState(1);
- const [pan, setPan] = useState({ x: 0, y: 0 });
-
- const canvasRef = useRef(null);
- const dragRef = useRef(null);
+ const getStoredTheme = () => {
+ if (typeof window === "undefined") return "dark" as const;
+ const stored = window.localStorage?.getItem("bros2-theme");
+ return stored === "light" ? "light" : "dark";
+ };
+ const [theme, setTheme] = useState<"dark" | "light">(getStoredTheme);
+
+ const flowWrapperRef = useRef(null);
+ const rfInstanceRef = useRef | null>(null);
const saveTimeoutRef = useRef | null>(null);
const hasHydratedRef = useRef(false);
- const panRef = useRef<{
- active: boolean;
- startX: number;
- startY: number;
- originX: number;
- originY: number;
- }>({ active: false, startX: 0, startY: 0, originX: 0, originY: 0 });
+ const lastSavedSigRef = useRef(null);
+ const runtimeNodesRef = useRef |