Files
n8n_Demo/n8n-n8n-1.109.2/packages/frontend/editor-ui/src/composables/useNodeDirtiness.ts
2025-09-08 04:48:28 +08:00

311 lines
8.1 KiB
TypeScript
Executable File

import {
AddConnectionCommand,
AddNodeCommand,
BulkCommand,
EnableNodeToggleCommand,
RemoveConnectionCommand,
RemoveNodeCommand,
type Undoable,
} from '@/models/history';
import { useHistoryStore } from '@/stores/history.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { CanvasNodeDirtiness, type CanvasNodeDirtinessType } from '@/types';
import type { INodeConnections, NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { computed } from 'vue';
/**
* Does the command make the given node dirty?
*/
function shouldCommandMarkDirty(
command: Undoable,
nodeName: string,
siblingCommands: Undoable[],
getIncomingConnections: (nodeName: string) => INodeConnections,
getOutgoingConnectors: (nodeName: string) => INodeConnections,
): boolean {
if (command instanceof BulkCommand) {
return command.commands.some((cmd) =>
shouldCommandMarkDirty(
cmd,
nodeName,
command.commands,
getIncomingConnections,
getOutgoingConnectors,
),
);
}
if (command instanceof AddConnectionCommand) {
return command.connectionData[1]?.node === nodeName;
}
if (command instanceof RemoveConnectionCommand) {
const [from, to] = command.connectionData;
if (to.node !== nodeName) {
return false;
}
// the connection was removed along with its source node
return siblingCommands.some(
(sibling) => sibling instanceof RemoveNodeCommand && sibling.node.name === from.node,
);
}
const incomingNodes = Object.values(getIncomingConnections(nodeName))
.flat()
.flat()
.filter((connection) => connection !== null)
.map((connection) => connection.node);
if (command instanceof AddNodeCommand) {
return incomingNodes.includes(command.node.name);
}
if (command instanceof EnableNodeToggleCommand) {
return (
incomingNodes.includes(command.nodeName) &&
(command.newState ||
Object.keys(getOutgoingConnectors(command.nodeName)).some(
(type) => (type as NodeConnectionType) !== NodeConnectionTypes.Main,
))
);
}
return false;
}
/**
* If given node is part of a loop, returns the set of nodes that forms the loop, otherwise returns undefined.
*/
function findLoop(
nodeName: string,
visited: string[],
getIncomingConnections: (nodeName: string) => INodeConnections,
): string[] | undefined {
const index = visited.indexOf(nodeName);
if (index >= 0) {
return visited.slice(index);
}
const newVisited = [...visited, nodeName];
for (const [type, typeConnections] of Object.entries(getIncomingConnections(nodeName))) {
if ((type as NodeConnectionType) !== NodeConnectionTypes.Main) {
continue;
}
for (const connections of typeConnections) {
for (const { node } of connections ?? []) {
const loop = findLoop(node, newVisited, getIncomingConnections);
if (loop) {
return loop;
}
}
}
}
return undefined;
}
/**
* Determines the subgraph that is affected by changes made after the last (partial) execution
*/
export function useNodeDirtiness() {
const historyStore = useHistoryStore();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
function getParentSubNodes(nodeName: string) {
return Object.entries(workflowsStore.incomingConnectionsByNodeName(nodeName))
.filter(([type]) => (type as NodeConnectionType) !== NodeConnectionTypes.Main)
.flatMap(([, typeConnections]) => typeConnections.flat().filter((conn) => conn !== null));
}
function getDirtinessByParametersUpdate(
nodeName: string,
after: number,
): CanvasNodeDirtinessType | undefined {
if ((workflowsStore.getParametersLastUpdate(nodeName) ?? 0) > after) {
return CanvasNodeDirtiness.PARAMETERS_UPDATED;
}
for (const connection of getParentSubNodes(nodeName)) {
if (getDirtinessByParametersUpdate(connection.node, after) !== undefined) {
return CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
return undefined;
}
function getDirtinessByConnectionsUpdate(
nodeName: string,
after: number,
): CanvasNodeDirtinessType | undefined {
for (let i = historyStore.undoStack.length - 1; i >= 0; i--) {
const command = historyStore.undoStack[i];
if (command.getTimestamp() < after) {
break;
}
if (
shouldCommandMarkDirty(
command,
nodeName,
[],
workflowsStore.incomingConnectionsByNodeName,
workflowsStore.outgoingConnectionsByNodeName,
)
) {
return CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED;
}
}
for (const connection of getParentSubNodes(nodeName)) {
if (getDirtinessByConnectionsUpdate(connection.node, after) !== undefined) {
return CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
return undefined;
}
/**
* Depth of node is defined as the minimum distance (number of connections) from the trigger node
*/
const depthByName = computed(() => {
const depth: Record<string, number> = {};
function setDepthRecursively(nodeName: string, current: number, visited: Set<string>) {
if (visited.has(nodeName)) {
return;
}
const myVisited = new Set<string>(visited);
myVisited.add(nodeName);
for (const [type, typeConnections] of Object.entries(
workflowsStore.outgoingConnectionsByNodeName(nodeName),
)) {
if ((type as NodeConnectionType) !== NodeConnectionTypes.Main) {
continue;
}
for (const connections of typeConnections) {
for (const { node } of connections ?? []) {
if (!depth[node] || depth[node] > current) {
depth[node] = current;
}
setDepthRecursively(node, current + 1, myVisited);
}
}
}
}
for (const startNode of workflowsStore.allNodes) {
const hasIncomingNode =
Object.keys(workflowsStore.incomingConnectionsByNodeName(startNode.name)).length > 0;
if (hasIncomingNode) {
continue;
}
depth[startNode.name] = 0;
setDepthRecursively(startNode.name, 1, new Set());
}
return depth;
});
const dirtinessByName = computed(() => {
// Do not highlight dirtiness if new partial execution is not enabled
if (settingsStore.partialExecutionVersion === 1) {
return {};
}
const dirtiness: Record<string, CanvasNodeDirtinessType | undefined> = {};
const runDataByNode = workflowsStore.getWorkflowRunData ?? {};
function setDirtiness(nodeName: string, value: CanvasNodeDirtinessType) {
dirtiness[nodeName] = dirtiness[nodeName] ?? value;
const loop = findLoop(nodeName, [], workflowsStore.incomingConnectionsByNodeName);
if (!loop) {
return;
}
const loopEntryNodeName = [...loop].sort(
(a, b) =>
(depthByName.value[a] ?? Number.MAX_SAFE_INTEGER) -
(depthByName.value[b] ?? Number.MAX_SAFE_INTEGER),
)?.[0];
if (loopEntryNodeName && depthByName.value[loopEntryNodeName]) {
// If a node in a loop becomes dirty, the first node in the loop should also be dirty
dirtiness[loopEntryNodeName] =
dirtiness[loopEntryNodeName] ?? CanvasNodeDirtiness.UPSTREAM_DIRTY;
}
}
for (const [nodeName, runData] of Object.entries(runDataByNode)) {
const runAt = runData[0]?.startTime ?? 0;
if (!runAt) {
continue;
}
const parameterUpdate = getDirtinessByParametersUpdate(nodeName, runAt);
if (parameterUpdate) {
setDirtiness(nodeName, parameterUpdate);
continue;
}
const connectionUpdate = getDirtinessByConnectionsUpdate(nodeName, runAt);
if (connectionUpdate) {
setDirtiness(nodeName, connectionUpdate);
continue;
}
const hasInputPinnedDataChanged = Object.values(
workflowsStore.incomingConnectionsByNodeName(nodeName),
)
.flat()
.flat()
.filter((connection) => connection !== null)
.some((connection) => {
const pinnedDataLastUpdatedAt =
workflowsStore.getPinnedDataLastUpdate(connection.node) ?? 0;
return pinnedDataLastUpdatedAt > runAt;
});
if (hasInputPinnedDataChanged) {
setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
continue;
}
const pinnedDataLastRemovedAt = workflowsStore.getPinnedDataLastRemovedAt(nodeName) ?? 0;
if (pinnedDataLastRemovedAt > runAt) {
setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
continue;
}
}
return dirtiness;
});
return { dirtinessByName };
}