| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import type {Trace} from '../../public/trace'; |
| import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules'; |
| import type {CleanupManager} from './query_builder/cleanup_manager'; |
| import type {NodeActionHandlers} from './node_actions'; |
| import {createDeferredNodeActions} from './node_actions'; |
| import {type QueryNode, NodeType, singleNodeOperation} from './query_node'; |
| import {nodeRegistry, type PreCreateState} from './query_builder/node_registry'; |
| import { |
| getAllNodes, |
| insertNodeBetween, |
| getInputNodeAtPort, |
| getAllInputNodes, |
| isNodeUndocked, |
| findDockedChildren, |
| applyUndockLayouts, |
| addConnection, |
| removeConnection, |
| notifyNextNodes, |
| captureAllChildConnections, |
| } from './query_builder/graph_utils'; |
| import type {DataExplorerState} from './data_explorer'; |
| |
| // Dependencies needed by node CRUD operations. |
| export interface NodeCrudDeps { |
| readonly trace: Trace; |
| readonly sqlModules: SqlModules; |
| readonly onStateUpdate: ( |
| update: |
| | DataExplorerState |
| | ((currentState: DataExplorerState) => DataExplorerState), |
| ) => void; |
| readonly cleanupManager?: CleanupManager; |
| readonly initializedNodes: Set<string>; |
| readonly nodeActionHandlers: NodeActionHandlers; |
| } |
| |
| // Gets the primary input parent of a node. |
| // Returns undefined for source nodes and multi-source nodes. |
| function getPrimaryParent(node: QueryNode): QueryNode | undefined { |
| if ('primaryInput' in node) { |
| return node.primaryInput; |
| } |
| return undefined; |
| } |
| |
| // Disconnects a node from all its parents and children. |
| function disconnectNodeFromGraph(node: QueryNode): void { |
| const allParents = getAllInputNodes(node); |
| for (const parent of allParents) { |
| removeConnection(parent, node); |
| } |
| const children = [...node.nextNodes]; |
| for (const child of children) { |
| removeConnection(node, child); |
| } |
| } |
| |
| // Cleans up all existing nodes (drops materialized tables) and clears |
| // the initialized nodes set. Used when replacing the entire graph state. |
| export async function cleanupExistingNodes( |
| cleanupManager: CleanupManager | undefined, |
| initializedNodes: Set<string>, |
| rootNodes: QueryNode[], |
| ): Promise<void> { |
| if (cleanupManager !== undefined) { |
| const allNodes = getAllNodes(rootNodes); |
| await cleanupManager.cleanupNodes(allNodes); |
| } |
| initializedNodes.clear(); |
| } |
| |
| export async function addOperationNode( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| parentNode: QueryNode, |
| derivedNodeId: string, |
| ): Promise<QueryNode | undefined> { |
| const descriptor = nodeRegistry.get(derivedNodeId); |
| if (descriptor) { |
| let initialState: PreCreateState | PreCreateState[] | null = {}; |
| if (descriptor.preCreate) { |
| initialState = await descriptor.preCreate({ |
| sqlModules: deps.sqlModules, |
| }); |
| } |
| |
| if (initialState === null) { |
| return; |
| } |
| |
| // For operation nodes, we only support single node creation |
| // (multi-select only makes sense for source nodes) |
| if (Array.isArray(initialState)) { |
| console.warn( |
| 'Operation nodes do not support multi-node creation from preCreate', |
| ); |
| return; |
| } |
| |
| // Use a wrapper object to hold the node reference (allows mutation without 'let') |
| const nodeRef: {current?: QueryNode} = {}; |
| |
| const newNode = descriptor.factory(initialState, { |
| allNodes: state.rootNodes, |
| context: { |
| sqlModules: deps.sqlModules, |
| trace: deps.trace, |
| actions: createDeferredNodeActions(nodeRef, deps.nodeActionHandlers), |
| }, |
| }); |
| |
| // Set the reference so the callback can use it |
| nodeRef.current = newNode; |
| |
| // Mark this node as initialized |
| deps.initializedNodes.add(newNode.nodeId); |
| |
| if (singleNodeOperation(newNode.type)) { |
| // Group nodes never dock children because their inner subgraph is |
| // hidden; docking would visually merge a child into the group box. |
| const parentIsGroup = parentNode.type === NodeType.kGroup; |
| // Check if parent has any undocked (detached) children. |
| // If so, the new node should also be undocked rather than inserted |
| // between the parent and its children. |
| const hasUndockedChildren = |
| parentIsGroup || |
| parentNode.nextNodes.some((child) => |
| isNodeUndocked(child, state.nodeLayouts), |
| ); |
| |
| if (hasUndockedChildren) { |
| // Capture docked children before modifying connections — they need |
| // to be undocked since docking only works with a single child. |
| const dockedChildren = findDockedChildren( |
| parentNode, |
| state.nodeLayouts, |
| ); |
| |
| addConnection(parentNode, newNode); |
| |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| // Undock docked children and position the new node, staggered |
| // after them so nothing overlaps. |
| nodeLayouts: applyUndockLayouts( |
| parentNode, |
| [...dockedChildren, newNode], |
| currentState.nodeLayouts, |
| ), |
| selectedNodes: new Set([newNode.nodeId]), |
| })); |
| } else { |
| // No undocked children: insert between the target and its children |
| insertNodeBetween(parentNode, newNode, addConnection, removeConnection); |
| |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| selectedNodes: new Set([newNode.nodeId]), |
| })); |
| } |
| } else { |
| // For multi-source nodes: just connect and add to root nodes |
| // Don't insert in-between - the node combines multiple sources |
| |
| // Undock docked children before adding (docking requires exactly one child) |
| const dockedChildren = findDockedChildren(parentNode, state.nodeLayouts); |
| |
| addConnection(parentNode, newNode); |
| |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [...currentState.rootNodes, newNode], |
| nodeLayouts: applyUndockLayouts( |
| parentNode, |
| dockedChildren, |
| currentState.nodeLayouts, |
| ), |
| selectedNodes: new Set([newNode.nodeId]), |
| })); |
| } |
| |
| return newNode; |
| } |
| |
| console.warn( |
| `Cannot add operation node: unknown type '${derivedNodeId}' for source node ${parentNode.nodeId}`, |
| ); |
| return undefined; |
| } |
| |
| export async function addSourceNode( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| id: string, |
| ): Promise<void> { |
| const descriptor = nodeRegistry.get(id); |
| if (!descriptor) { |
| console.warn(`Cannot add source node: unknown node type '${id}'`); |
| return; |
| } |
| |
| let initialState: PreCreateState | PreCreateState[] | null = {}; |
| |
| if (descriptor.preCreate) { |
| initialState = await descriptor.preCreate({sqlModules: deps.sqlModules}); |
| } |
| |
| // User cancelled the preCreate dialog |
| if (initialState === null) { |
| return; |
| } |
| |
| // Handle both single node and multi-node creation |
| const statesToCreate = Array.isArray(initialState) |
| ? initialState |
| : [initialState]; |
| |
| const newNodes: QueryNode[] = []; |
| for (const stateItem of statesToCreate) { |
| try { |
| const newNode = descriptor.factory(stateItem, { |
| allNodes: state.rootNodes, |
| context: {trace: deps.trace, sqlModules: deps.sqlModules}, |
| }); |
| newNodes.push(newNode); |
| } catch (error) { |
| console.error('Failed to create node:', error); |
| // Continue creating other nodes even if one fails |
| } |
| } |
| |
| // If no nodes were successfully created, return early |
| // (errors were already logged in the try-catch above) |
| if (newNodes.length === 0) { |
| console.warn('No nodes were created from the preCreate result'); |
| return; |
| } |
| |
| const lastNode = newNodes[newNodes.length - 1]; |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [...currentState.rootNodes, ...newNodes], |
| selectedNodes: new Set([lastNode.nodeId]), |
| })); |
| } |
| |
| export async function addAndConnectTable( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| tableName: string, |
| targetNode: QueryNode, |
| portIndex: number, |
| ): Promise<void> { |
| // Get the table descriptor |
| const descriptor = nodeRegistry.get('table'); |
| if (!descriptor) { |
| console.warn("Cannot add table: 'table' node type not found in registry"); |
| return; |
| } |
| |
| // Find the table in SQL modules |
| const sqlTable = deps.sqlModules |
| .listTables() |
| .find((t) => t.name === tableName); |
| if (!sqlTable) { |
| console.warn(`Table ${tableName} not found in SQL modules`); |
| return; |
| } |
| |
| // Create the table node with the specific table (bypass the modal) |
| const newNode = descriptor.factory( |
| {sqlTable: tableName}, |
| { |
| allNodes: state.rootNodes, |
| context: {sqlModules: deps.sqlModules, trace: deps.trace}, |
| }, |
| ); |
| |
| // Add connection from the new table node to the target node |
| addConnection(newNode, targetNode, portIndex); |
| |
| // Add the new node to root nodes |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [...currentState.rootNodes, newNode], |
| })); |
| } |
| |
| export async function insertNodeAtPort( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| targetNode: QueryNode, |
| portIndex: number, |
| descriptorKey: string, |
| ): Promise<void> { |
| const descriptor = nodeRegistry.get(descriptorKey); |
| if (!descriptor) { |
| console.warn( |
| `Cannot insert ${descriptorKey} node: '${descriptorKey}' not found in registry`, |
| ); |
| return; |
| } |
| |
| const inputNode = getInputNodeAtPort(targetNode, portIndex); |
| if (!inputNode) { |
| console.warn(`No input node found at port ${portIndex}`); |
| return; |
| } |
| |
| const newNode = descriptor.factory( |
| { |
| sqlModules: deps.sqlModules, |
| trace: deps.trace, |
| }, |
| {allNodes: state.rootNodes}, |
| ); |
| |
| removeConnection(inputNode, targetNode); |
| addConnection(inputNode, newNode); |
| addConnection(newNode, targetNode, portIndex); |
| |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [...currentState.rootNodes, newNode], |
| selectedNodes: new Set([newNode.nodeId]), |
| })); |
| } |
| |
| export async function deleteNode( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| node: QueryNode, |
| ): Promise<void> { |
| // STEP 1: Clean up resources (SQL tables, JS subscriptions, etc.) |
| if (deps.cleanupManager !== undefined) { |
| try { |
| await deps.cleanupManager.cleanupNode(node); |
| } catch (error) { |
| // Log error but continue with deletion |
| console.error('Failed to cleanup node resources:', error); |
| } |
| } |
| |
| // STEP 2: Capture graph structure BEFORE modification |
| // We need to capture this info before removeConnection() clears the references |
| const primaryParent = getPrimaryParent(node); |
| const childConnections = captureAllChildConnections(node); |
| const allInputs = getAllInputNodes(node); // Capture ALL parents (primary + secondary) |
| |
| // STEP 3: Remove the node from the graph |
| disconnectNodeFromGraph(node); |
| |
| // STEP 4: Reconnect primary parent to children (if exists) |
| // This bypasses the deleted node, maintaining data flow for PRIMARY connections only. |
| // |
| // IMPORTANT RULES: |
| // 1. Only reconnect if deleted node fed child's PRIMARY input (portIndex === undefined) |
| // 2. Secondary connections are specific to the deleted node - DROP them, don't reconnect |
| // 3. Skip reconnection if parent is already connected to avoid duplicates |
| // 4. Transfer deleted node's layout to docked children so they can render at same position |
| const reconnectedChildren: QueryNode[] = []; |
| const updatedNodeLayouts = new Map(state.nodeLayouts); |
| const deletedNodeLayout = state.nodeLayouts.get(node.nodeId); |
| |
| if (primaryParent !== undefined) { |
| let layoutOffsetCount = 0; |
| for (const {child, portIndex} of childConnections) { |
| // If deleted node fed child's secondary input, DROP the connection |
| // Secondary inputs are specific to the deleted node (e.g., intervals for FilterDuring) |
| if (portIndex !== undefined) { |
| continue; // Don't reconnect secondary connections |
| } |
| |
| // Check if parent is already connected to this child |
| if (primaryParent.nextNodes.includes(child)) { |
| continue; // Already connected - don't create duplicates |
| } |
| |
| // Reconnect: maintain primary data flow (A → B → C becomes A → C) |
| addConnection(primaryParent, child, portIndex); |
| reconnectedChildren.push(child); |
| |
| // If child was docked (no layout) and deleted node had a layout, |
| // transfer the layout to the child so it renders at the same position |
| // For multiple children, offset their positions to avoid overlapping |
| const childHasNoLayout = !state.nodeLayouts.has(child.nodeId); |
| if (childHasNoLayout && deletedNodeLayout !== undefined) { |
| const offsetX = layoutOffsetCount * 30; // Offset each child by 30px |
| const offsetY = layoutOffsetCount * 30; |
| updatedNodeLayouts.set(child.nodeId, { |
| x: deletedNodeLayout.x + offsetX, |
| y: deletedNodeLayout.y + offsetY, |
| }); |
| layoutOffsetCount++; |
| } |
| } |
| } |
| |
| // STEP 4b: Check if reconnected children can actually be rendered |
| // A child becomes "unrenderable" if: |
| // - It was reconnected to a parent |
| // - It has no layout (was docked to deleted node) |
| // - Parent has multiple children (can't render as docked anymore) |
| const unrenderableChildren: QueryNode[] = []; |
| if (primaryParent !== undefined && reconnectedChildren.length > 0) { |
| const parentHasMultipleChildren = primaryParent.nextNodes.length > 1; |
| for (const child of reconnectedChildren) { |
| // Check the UPDATED layouts, not the old state |
| const childHasNoLayout = !updatedNodeLayouts.has(child.nodeId); |
| // If child has no layout and parent has multiple children, |
| // the child can't be rendered (not as docked, not as root) |
| if (childHasNoLayout && parentHasMultipleChildren) { |
| unrenderableChildren.push(child); |
| } |
| } |
| } |
| |
| // STEP 5: Update root nodes list |
| // Use a Set to prevent duplicate root nodes |
| const newRootNodesSet = new Set(state.rootNodes.filter((n) => n !== node)); |
| |
| // Add orphaned children to root nodes so they remain visible |
| // Children are orphaned ONLY if: |
| // 1. There was no primary parent to reconnect them to, AND |
| // 2. They were connected via PRIMARY input (not secondary) |
| // Children connected via secondary input still have their own primary parent! |
| if (primaryParent === undefined && childConnections.length > 0) { |
| // Only children connected via primary input are truly orphaned |
| const orphanedChildren = childConnections |
| .filter((c) => c.portIndex === undefined) // Primary input only |
| .map((c) => c.child); |
| |
| for (const child of orphanedChildren) { |
| newRootNodesSet.add(child); |
| } |
| |
| // Transfer deleted node's layout to orphaned children so they appear at same position |
| // For multiple children, offset their positions to avoid overlapping |
| if (deletedNodeLayout !== undefined) { |
| let layoutOffsetCount = 0; |
| for (const child of orphanedChildren) { |
| const childHasNoLayout = !updatedNodeLayouts.has(child.nodeId); |
| if (childHasNoLayout) { |
| const offsetX = layoutOffsetCount * 30; // Offset each child by 30px |
| const offsetY = layoutOffsetCount * 30; |
| updatedNodeLayouts.set(child.nodeId, { |
| x: deletedNodeLayout.x + offsetX, |
| y: deletedNodeLayout.y + offsetY, |
| }); |
| layoutOffsetCount++; |
| } |
| } |
| } |
| } |
| |
| // Add unrenderable children to root nodes so they become visible |
| // These are children that were reconnected but can't be rendered as docked |
| for (const child of unrenderableChildren) { |
| newRootNodesSet.add(child); |
| } |
| |
| // STEP 5b: Promote orphaned input providers to root nodes |
| // Simple rule: If a node was NOT a root node, and we deleted the node that |
| // consumed it, then it should become a root node. |
| const orphanedInputs: QueryNode[] = []; |
| for (const inputNode of allInputs) { |
| // Check if this input node becomes orphaned: |
| // 1. It was NOT originally a root node |
| // 2. After deletion, it has no consumers (nextNodes is empty) |
| const wasNotRoot = !state.rootNodes.includes(inputNode); |
| const hasNoConsumers = inputNode.nextNodes.length === 0; |
| |
| if (wasNotRoot && hasNoConsumers) { |
| orphanedInputs.push(inputNode); |
| } |
| } |
| |
| for (const inputNode of orphanedInputs) { |
| newRootNodesSet.add(inputNode); |
| } |
| |
| const newRootNodes = Array.from(newRootNodesSet); |
| |
| // STEP 5c: Remove the deleted node's layout from the map |
| // Now that we've transferred the layout to children/orphans, clean it up |
| updatedNodeLayouts.delete(node.nodeId); |
| |
| // STEP 6: Trigger validation on affected children |
| // Children need to re-validate because their inputs have changed |
| // (either reconnected to a different parent or lost their parent entirely) |
| for (const {child} of childConnections) { |
| child.onPrevNodesUpdated?.(); |
| } |
| |
| // Also notify orphaned input providers that their consumers changed |
| for (const inputNode of orphanedInputs) { |
| notifyNextNodes(inputNode); |
| } |
| |
| // STEP 7: Commit state changes |
| deps.onStateUpdate((currentState) => { |
| // Update selection based on current state (not stale state) |
| // This is important for multi-node deletion where state changes between deletions |
| const newSelectedNodes = new Set(currentState.selectedNodes); |
| newSelectedNodes.delete(node.nodeId); |
| |
| return { |
| ...currentState, |
| rootNodes: newRootNodes, |
| selectedNodes: newSelectedNodes, |
| nodeLayouts: updatedNodeLayouts, |
| }; |
| }); |
| } |
| |
| // Delete all currently selected nodes. |
| // Batches all deletions into a single state update to create one undo point. |
| export async function deleteSelectedNodes( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| ): Promise<void> { |
| const selectedNodeIds = new Set(state.selectedNodes); |
| |
| if (selectedNodeIds.size === 0) { |
| return; |
| } |
| |
| // Get all nodes to delete |
| const allNodes = getAllNodes(state.rootNodes); |
| const nodesToDelete = allNodes.filter((n) => selectedNodeIds.has(n.nodeId)); |
| |
| if (nodesToDelete.length === 0) { |
| return; |
| } |
| |
| // STEP 1: Clean up resources for all nodes (async operations) |
| if (deps.cleanupManager !== undefined) { |
| for (const node of nodesToDelete) { |
| try { |
| await deps.cleanupManager.cleanupNode(node); |
| } catch (error) { |
| console.error('Failed to cleanup node resources:', error); |
| } |
| } |
| } |
| |
| // STEP 2: Capture graph info and perform all deletions in a single state update |
| deps.onStateUpdate((currentState) => { |
| const nodesToDeleteSet = new Set(nodesToDelete); |
| const updatedNodeLayouts = new Map(currentState.nodeLayouts); |
| const newRootNodesSet = new Set(currentState.rootNodes); |
| const affectedChildren: QueryNode[] = []; |
| const orphanedInputs: QueryNode[] = []; |
| |
| // Process each node deletion |
| for (const node of nodesToDelete) { |
| // Capture info before disconnection |
| const primaryParent = getPrimaryParent(node); |
| const childConnections = captureAllChildConnections(node); |
| const allInputs = getAllInputNodes(node); |
| |
| // Disconnect from graph |
| disconnectNodeFromGraph(node); |
| |
| // Remove from root nodes |
| newRootNodesSet.delete(node); |
| |
| // Remove layout |
| const deletedNodeLayout = updatedNodeLayouts.get(node.nodeId); |
| updatedNodeLayouts.delete(node.nodeId); |
| |
| // Reconnect primary parent to children (if parent is not also being deleted) |
| if (primaryParent !== undefined && !nodesToDeleteSet.has(primaryParent)) { |
| let layoutOffsetCount = 0; |
| for (const {child, portIndex} of childConnections) { |
| // Skip if child is also being deleted |
| if (nodesToDeleteSet.has(child)) { |
| continue; |
| } |
| |
| // Only reconnect primary connections |
| if (portIndex === undefined) { |
| if (!primaryParent.nextNodes.includes(child)) { |
| addConnection(primaryParent, child, portIndex); |
| affectedChildren.push(child); |
| |
| // Transfer layout if child was docked |
| const childHasNoLayout = !updatedNodeLayouts.has(child.nodeId); |
| if (childHasNoLayout && deletedNodeLayout !== undefined) { |
| const offsetX = layoutOffsetCount * 30; |
| const offsetY = layoutOffsetCount * 30; |
| updatedNodeLayouts.set(child.nodeId, { |
| x: deletedNodeLayout.x + offsetX, |
| y: deletedNodeLayout.y + offsetY, |
| }); |
| layoutOffsetCount++; |
| } |
| } |
| } |
| } |
| } |
| |
| // Handle orphaned children (no parent or parent was deleted) |
| if (primaryParent === undefined || nodesToDeleteSet.has(primaryParent)) { |
| let layoutOffsetCount = 0; |
| for (const {child, portIndex} of childConnections) { |
| // Skip if child is also being deleted |
| if (nodesToDeleteSet.has(child)) { |
| continue; |
| } |
| |
| // Only orphan primary connections |
| if (portIndex === undefined) { |
| newRootNodesSet.add(child); |
| affectedChildren.push(child); |
| |
| // Transfer layout |
| const childHasNoLayout = !updatedNodeLayouts.has(child.nodeId); |
| if (childHasNoLayout && deletedNodeLayout !== undefined) { |
| const offsetX = layoutOffsetCount * 30; |
| const offsetY = layoutOffsetCount * 30; |
| updatedNodeLayouts.set(child.nodeId, { |
| x: deletedNodeLayout.x + offsetX, |
| y: deletedNodeLayout.y + offsetY, |
| }); |
| layoutOffsetCount++; |
| } |
| } |
| } |
| } |
| |
| // Handle orphaned input providers |
| for (const inputNode of allInputs) { |
| // Skip if input is also being deleted |
| if (nodesToDeleteSet.has(inputNode)) { |
| continue; |
| } |
| |
| const wasNotRoot = !currentState.rootNodes.includes(inputNode); |
| const hasNoConsumers = inputNode.nextNodes.length === 0; |
| |
| if (wasNotRoot && hasNoConsumers) { |
| newRootNodesSet.add(inputNode); |
| orphanedInputs.push(inputNode); |
| } |
| } |
| } |
| |
| // Trigger validation on affected nodes |
| for (const child of affectedChildren) { |
| child.onPrevNodesUpdated?.(); |
| } |
| for (const inputNode of orphanedInputs) { |
| notifyNextNodes(inputNode); |
| } |
| |
| // Clear selection |
| return { |
| ...currentState, |
| rootNodes: Array.from(newRootNodesSet), |
| selectedNodes: new Set<string>(), |
| nodeLayouts: updatedNodeLayouts, |
| }; |
| }); |
| } |
| |
| export async function clearAllNodes( |
| deps: NodeCrudDeps, |
| state: DataExplorerState, |
| ): Promise<void> { |
| await cleanupExistingNodes( |
| deps.cleanupManager, |
| deps.initializedNodes, |
| state.rootNodes, |
| ); |
| |
| deps.onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [], |
| selectedNodes: new Set(), |
| nodeLayouts: new Map(), |
| labels: [], |
| })); |
| } |
| |
| export function duplicateNode( |
| onStateUpdate: ( |
| update: (currentState: DataExplorerState) => DataExplorerState, |
| ) => void, |
| node: QueryNode, |
| ): void { |
| onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: [...currentState.rootNodes, node.clone()], |
| })); |
| } |
| |
| export function removeNodeConnection( |
| state: DataExplorerState, |
| onStateUpdate: ( |
| update: (currentState: DataExplorerState) => DataExplorerState, |
| ) => void, |
| fromNode: QueryNode, |
| toNode: QueryNode, |
| isSecondaryInput: boolean, |
| ): void { |
| // NOTE: The basic connection removal is already handled by graph.ts |
| // This callback handles higher-level logic like reconnection and state updates |
| |
| // Only reconnect fromNode to toNode's children when removing a PRIMARY input. |
| // When removing a SECONDARY input, we should NOT reconnect - the secondary |
| // input node is just an auxiliary input (like intervals for FilterDuring) |
| // and should not be connected to the children of the node it was feeding into. |
| const shouldReconnect = |
| !isSecondaryInput && |
| fromNode.nextNodes.length === 0 && |
| toNode.nextNodes.length > 0; |
| |
| if (shouldReconnect) { |
| // Reconnect fromNode to all of toNode's children (bypass toNode) |
| for (const child of toNode.nextNodes) { |
| addConnection(fromNode, child); |
| } |
| } |
| |
| // Handle state updates based on node type |
| if ('primaryInput' in toNode && toNode.primaryInput === undefined) { |
| // toNode is a ModificationNode that's now orphaned |
| // Add it to rootNodes so it remains visible (but invalid) |
| const newRootNodes = state.rootNodes.includes(toNode) |
| ? state.rootNodes |
| : [...state.rootNodes, toNode]; |
| |
| onStateUpdate((currentState) => ({ |
| ...currentState, |
| rootNodes: newRootNodes, |
| })); |
| } else if ('secondaryInputs' in toNode) { |
| // toNode is a MultiSourceNode - just trigger a state update |
| onStateUpdate((currentState) => ({...currentState})); |
| } |
| } |