feat: simplify paths
This commit is contained in:
80
frontend/package-lock.json
generated
80
frontend/package-lock.json
generated
@@ -28,6 +28,8 @@
|
|||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-traversal": "^0.3.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"markdown-to-jsx": "^9.7.9",
|
"markdown-to-jsx": "^9.7.9",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
@@ -4845,6 +4847,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
@@ -5350,6 +5361,60 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/graphology": {
|
||||||
|
"version": "0.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
|
||||||
|
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"events": "^3.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graphology-indices": {
|
||||||
|
"version": "0.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
|
||||||
|
"integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphology-utils": "^2.4.2",
|
||||||
|
"mnemonist": "^0.39.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graphology-traversal": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology-traversal/-/graphology-traversal-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-lGLrLKEDKtNgAKgHVhVftKf3cb/nuWwuVPQZHXRnN90JWn0RSjco/s+NB2ARSlMapEMlbnPgv6j++427yTnU3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphology-indices": "^0.17.0",
|
||||||
|
"graphology-utils": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graphology-types": {
|
||||||
|
"version": "0.24.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
|
||||||
|
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/graphology-utils": {
|
||||||
|
"version": "2.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
|
||||||
|
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphology-types": ">=0.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graphql": {
|
"node_modules/graphql": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz",
|
||||||
@@ -6163,6 +6228,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mnemonist": {
|
||||||
|
"version": "0.39.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
|
||||||
|
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"obliterator": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -6449,6 +6523,12 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obliterator": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zustand": "^5.0.2",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
@@ -31,6 +30,8 @@
|
|||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-traversal": "^0.3.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"markdown-to-jsx": "^9.7.9",
|
"markdown-to-jsx": "^9.7.9",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
@@ -44,7 +45,8 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export {
|
|||||||
selectNodes,
|
selectNodes,
|
||||||
selectEdges,
|
selectEdges,
|
||||||
selectPathNodeIds,
|
selectPathNodeIds,
|
||||||
selectPathPausedSegmentNodeIds,
|
|
||||||
selectPathActiveSegmentNodeIds,
|
selectPathActiveSegmentNodeIds,
|
||||||
selectConnectionStatusForEdge,
|
selectConnectionStatusForEdge,
|
||||||
selectPathRoleForNode,
|
selectPathRoleForNode,
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ const initialGraph: GraphSlice = {
|
|||||||
edges: [],
|
edges: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PULSE_MS = 1000
|
||||||
|
|
||||||
const initialPath: PathSlice = {
|
const initialPath: PathSlice = {
|
||||||
updatingNodeIds: [],
|
|
||||||
triggerNodeIds: [],
|
triggerNodeIds: [],
|
||||||
pausedNodeIds: [],
|
pulseEndsAt: null,
|
||||||
errorNodeIds: [],
|
errorNodeIds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,33 +66,14 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
|||||||
case 'path/addTrigger': {
|
case 'path/addTrigger': {
|
||||||
const id = cmd.payload
|
const id = cmd.payload
|
||||||
if (prev.triggerNodeIds.includes(id)) return prev
|
if (prev.triggerNodeIds.includes(id)) return prev
|
||||||
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
|
return {
|
||||||
|
...prev,
|
||||||
|
triggerNodeIds: [...prev.triggerNodeIds, id],
|
||||||
|
pulseEndsAt: Date.now() + PULSE_MS,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case 'path/clearTriggers':
|
case 'path/clearTriggers':
|
||||||
return { ...prev, triggerNodeIds: [] }
|
return { ...prev, triggerNodeIds: [], pulseEndsAt: null }
|
||||||
case 'path/startUpdate': {
|
|
||||||
const id = cmd.payload
|
|
||||||
if (prev.updatingNodeIds.includes(id)) return prev
|
|
||||||
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
|
|
||||||
}
|
|
||||||
case 'path/endUpdate': {
|
|
||||||
const id = cmd.payload
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'path/setPaused': {
|
|
||||||
const { nodeId, paused } = cmd.payload
|
|
||||||
const has = prev.pausedNodeIds.includes(nodeId)
|
|
||||||
if (paused === has) return prev
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
pausedNodeIds: paused
|
|
||||||
? [...prev.pausedNodeIds, nodeId]
|
|
||||||
: prev.pausedNodeIds.filter((x) => x !== nodeId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'path/setError': {
|
case 'path/setError': {
|
||||||
const { nodeId, error } = cmd.payload
|
const { nodeId, error } = cmd.payload
|
||||||
const has = prev.errorNodeIds.includes(nodeId)
|
const has = prev.errorNodeIds.includes(nodeId)
|
||||||
@@ -104,10 +86,7 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'path/clearPathSession':
|
case 'path/clearPathSession':
|
||||||
return {
|
return { ...initialPath, errorNodeIds: prev.errorNodeIds }
|
||||||
...initialPath,
|
|
||||||
errorNodeIds: prev.errorNodeIds,
|
|
||||||
}
|
|
||||||
case 'path/clearErrors':
|
case 'path/clearErrors':
|
||||||
return { ...prev, errorNodeIds: [] }
|
return { ...prev, errorNodeIds: [] }
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
|
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
import {
|
||||||
|
getPathNodeIds,
|
||||||
|
getPathToUpdatingSegmentNodeIds,
|
||||||
|
type NodeAttributesMap,
|
||||||
|
} from '@/lib/graph/graphologyPath'
|
||||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||||
import type { CanvasStore } from './canvasStore.types'
|
import type { CanvasStore } from './canvasStore.types'
|
||||||
|
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
|
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Raw slices
|
// Raw slices
|
||||||
@@ -40,38 +45,44 @@ export function selectEdges(state: CanvasStore) {
|
|||||||
const emptySet = new Set<string>()
|
const emptySet = new Set<string>()
|
||||||
|
|
||||||
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
|
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
|
||||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
return edges.map((e) => ({ source: e.source, target: e.target, id: e.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build node attributes for graphology (nodeType, updateMode) from canvas nodes. */
|
||||||
|
function buildNodeAttributesMap(nodes: AppNode[]): NodeAttributesMap {
|
||||||
|
const map: NodeAttributesMap = {}
|
||||||
|
for (const n of nodes) {
|
||||||
|
const data = n.data as { updateMode?: 'auto' | 'manual' } | undefined
|
||||||
|
map[n.id] = {
|
||||||
|
nodeType: n.type ?? undefined,
|
||||||
|
updateMode: data?.updateMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectPathNodeIds(state: CanvasStore): Set<string> {
|
export function selectPathNodeIds(state: CanvasStore): Set<string> {
|
||||||
const { edges } = state.graph
|
const { nodes, edges } = state.graph
|
||||||
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
|
const { triggerNodeIds } = state.path
|
||||||
return getPathNodeIds(
|
return getPathNodeIds(
|
||||||
edgesAsGraphEdges(edges),
|
edgesAsGraphEdges(edges),
|
||||||
updatingNodeIds,
|
[],
|
||||||
triggerNodeIds,
|
triggerNodeIds,
|
||||||
pausedNodeIds
|
undefined,
|
||||||
)
|
buildNodeAttributesMap(nodes)
|
||||||
}
|
|
||||||
|
|
||||||
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
|
|
||||||
const pathNodeIds = selectPathNodeIds(state)
|
|
||||||
const { edges } = state.graph
|
|
||||||
const { triggerNodeIds, pausedNodeIds } = state.path
|
|
||||||
return getPausedSegmentNodeIds(
|
|
||||||
edgesAsGraphEdges(edges),
|
|
||||||
pathNodeIds,
|
|
||||||
triggerNodeIds,
|
|
||||||
pausedNodeIds
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
|
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||||
const pathNodeIds = selectPathNodeIds(state)
|
const { nodes, edges } = state.graph
|
||||||
const pausedSegment = selectPathPausedSegmentNodeIds(state)
|
const { triggerNodeIds, pulseEndsAt } = state.path
|
||||||
const active = new Set(pathNodeIds)
|
const pulseActive = pulseEndsAt != null
|
||||||
pausedSegment.forEach((id) => active.delete(id))
|
return getPathToUpdatingSegmentNodeIds(
|
||||||
return active
|
edgesAsGraphEdges(edges),
|
||||||
|
triggerNodeIds,
|
||||||
|
pulseActive,
|
||||||
|
buildNodeAttributesMap(nodes)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -84,14 +95,12 @@ export function selectConnectionStatusForEdge(
|
|||||||
target: string
|
target: string
|
||||||
): ConnectionStatus {
|
): ConnectionStatus {
|
||||||
const pathNodeIds = selectPathNodeIds(state)
|
const pathNodeIds = selectPathNodeIds(state)
|
||||||
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
|
|
||||||
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
|
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
|
||||||
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
|
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
|
||||||
return getConnectionStatus({
|
return getConnectionStatus({
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
pathNodeIds,
|
pathNodeIds,
|
||||||
pausedSegmentNodeIds,
|
|
||||||
activeSegmentNodeIds,
|
activeSegmentNodeIds,
|
||||||
errorTargetNodeIds,
|
errorTargetNodeIds,
|
||||||
})
|
})
|
||||||
@@ -108,7 +117,6 @@ export function selectPathRoleForNode(
|
|||||||
const pathNodeIds = selectPathNodeIds(state)
|
const pathNodeIds = selectPathNodeIds(state)
|
||||||
if (!pathNodeIds.has(nodeId)) return null
|
if (!pathNodeIds.has(nodeId)) return null
|
||||||
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
|
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
|
||||||
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
|
|
||||||
return 'on-path'
|
return 'on-path'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from './canvasStore'
|
} from './canvasStore'
|
||||||
import {
|
import {
|
||||||
selectPathNodeIds,
|
selectPathNodeIds,
|
||||||
selectPathPausedSegmentNodeIds,
|
|
||||||
selectPathActiveSegmentNodeIds,
|
selectPathActiveSegmentNodeIds,
|
||||||
selectConnectionStatusForEdge,
|
selectConnectionStatusForEdge,
|
||||||
selectPathRoleForNode,
|
selectPathRoleForNode,
|
||||||
@@ -141,42 +140,15 @@ describe('canvasStoreReducer', () => {
|
|||||||
expect(next.path.triggerNodeIds).toEqual([])
|
expect(next.path.triggerNodeIds).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('path/startUpdate adds to updatingNodeIds', () => {
|
it('path/addTrigger sets pulseEndsAt', () => {
|
||||||
const state: CanvasStore = { ...initialCanvasStore }
|
const state: CanvasStore = { ...initialCanvasStore }
|
||||||
|
const before = Date.now()
|
||||||
const next = canvasStoreReducer(state, {
|
const next = canvasStoreReducer(state, {
|
||||||
type: 'path/startUpdate',
|
type: 'path/addTrigger',
|
||||||
payload: 'n1',
|
payload: 'n1',
|
||||||
})
|
})
|
||||||
expect(next.path.updatingNodeIds).toEqual(['n1'])
|
expect(next.path.triggerNodeIds).toEqual(['n1'])
|
||||||
})
|
expect(next.path.pulseEndsAt).toBeGreaterThanOrEqual(before + 1000)
|
||||||
|
|
||||||
it('path/endUpdate removes from updatingNodeIds', () => {
|
|
||||||
const state: CanvasStore = {
|
|
||||||
...initialCanvasStore,
|
|
||||||
path: {
|
|
||||||
...initialCanvasStore.path,
|
|
||||||
updatingNodeIds: ['n1', 'n2'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const next = canvasStoreReducer(state, {
|
|
||||||
type: 'path/endUpdate',
|
|
||||||
payload: 'n1',
|
|
||||||
})
|
|
||||||
expect(next.path.updatingNodeIds).toEqual(['n2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('path/setPaused adds and removes paused node', () => {
|
|
||||||
const state: CanvasStore = { ...initialCanvasStore }
|
|
||||||
let next = canvasStoreReducer(state, {
|
|
||||||
type: 'path/setPaused',
|
|
||||||
payload: { nodeId: 'n1', paused: true },
|
|
||||||
})
|
|
||||||
expect(next.path.pausedNodeIds).toEqual(['n1'])
|
|
||||||
next = canvasStoreReducer(next, {
|
|
||||||
type: 'path/setPaused',
|
|
||||||
payload: { nodeId: 'n1', paused: false },
|
|
||||||
})
|
|
||||||
expect(next.path.pausedNodeIds).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('path/setError adds and removes error node', () => {
|
it('path/setError adds and removes error node', () => {
|
||||||
@@ -193,20 +165,18 @@ describe('canvasStoreReducer', () => {
|
|||||||
expect(next.path.errorNodeIds).toEqual([])
|
expect(next.path.errorNodeIds).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
|
it('path/clearPathSession resets trigger and pulse; keeps error', () => {
|
||||||
const state: CanvasStore = {
|
const state: CanvasStore = {
|
||||||
...initialCanvasStore,
|
...initialCanvasStore,
|
||||||
path: {
|
path: {
|
||||||
updatingNodeIds: ['u1'],
|
|
||||||
triggerNodeIds: ['t1'],
|
triggerNodeIds: ['t1'],
|
||||||
pausedNodeIds: ['p1'],
|
pulseEndsAt: Date.now() + 1000,
|
||||||
errorNodeIds: ['e1'],
|
errorNodeIds: ['e1'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
||||||
expect(next.path.updatingNodeIds).toEqual([])
|
|
||||||
expect(next.path.triggerNodeIds).toEqual([])
|
expect(next.path.triggerNodeIds).toEqual([])
|
||||||
expect(next.path.pausedNodeIds).toEqual([])
|
expect(next.path.pulseEndsAt).toBeNull()
|
||||||
expect(next.path.errorNodeIds).toEqual(['e1'])
|
expect(next.path.errorNodeIds).toEqual(['e1'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -280,9 +250,8 @@ describe('canvasStore selectors', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
path: {
|
path: {
|
||||||
updatingNodeIds: ['c'],
|
|
||||||
triggerNodeIds: ['a'],
|
triggerNodeIds: ['a'],
|
||||||
pausedNodeIds: [],
|
pulseEndsAt: null,
|
||||||
errorNodeIds: [],
|
errorNodeIds: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -311,15 +280,14 @@ describe('canvasStore selectors', () => {
|
|||||||
path: {
|
path: {
|
||||||
...initialCanvasStore.path,
|
...initialCanvasStore.path,
|
||||||
triggerNodeIds: ['a'],
|
triggerNodeIds: ['a'],
|
||||||
updatingNodeIds: ['b'],
|
pulseEndsAt: null,
|
||||||
pausedNodeIds: [],
|
|
||||||
errorNodeIds: ['b'],
|
errorNodeIds: ['b'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
|
it('selectPathRoleForNode returns trigger or on-path', () => {
|
||||||
const state: CanvasStore = {
|
const state: CanvasStore = {
|
||||||
...initialCanvasStore,
|
...initialCanvasStore,
|
||||||
graph: {
|
graph: {
|
||||||
@@ -329,13 +297,12 @@ describe('canvasStore selectors', () => {
|
|||||||
path: {
|
path: {
|
||||||
...initialCanvasStore.path,
|
...initialCanvasStore.path,
|
||||||
triggerNodeIds: ['a'],
|
triggerNodeIds: ['a'],
|
||||||
updatingNodeIds: ['b'],
|
pulseEndsAt: null,
|
||||||
pausedNodeIds: [],
|
|
||||||
errorNodeIds: [],
|
errorNodeIds: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
||||||
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
|
expect(selectPathRoleForNode(state, 'b')).toBe('on-path')
|
||||||
expect(selectPathRoleForNode(state, 'x')).toBe(null)
|
expect(selectPathRoleForNode(state, 'x')).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -378,14 +345,13 @@ describe('canvas store integration', () => {
|
|||||||
expect(state.graph.nodes[0].id).toBe('test-1')
|
expect(state.graph.nodes[0].id).toBe('test-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
|
it('dispatch path/addTrigger updates path and starts pulse', () => {
|
||||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
||||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
||||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
||||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
|
|
||||||
const state = getCanvasStore()
|
const state = getCanvasStore()
|
||||||
expect(state.path.triggerNodeIds).toContain('a')
|
expect(state.path.triggerNodeIds).toContain('a')
|
||||||
expect(state.path.updatingNodeIds).toContain('b')
|
expect(state.path.pulseEndsAt).not.toBeNull()
|
||||||
const pathIds = selectPathNodeIds(state)
|
const pathIds = selectPathNodeIds(state)
|
||||||
expect(pathIds.has('a')).toBe(true)
|
expect(pathIds.has('a')).toBe(true)
|
||||||
expect(pathIds.has('b')).toBe(true)
|
expect(pathIds.has('b')).toBe(true)
|
||||||
@@ -396,4 +362,53 @@ describe('canvas store integration', () => {
|
|||||||
const state = getCanvasStore()
|
const state = getCanvasStore()
|
||||||
expect(state.ui.fullscreenNodeId).toBe('full-node')
|
expect(state.ui.fullscreenNodeId).toBe('full-node')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pulse: addTrigger starts a short "updating" pulse along downstream path.
|
||||||
|
it('path-per-sink: addTrigger starts pulse, all downstream edges show updating', () => {
|
||||||
|
const nodes: AppNode[] = [
|
||||||
|
{
|
||||||
|
id: 'var_001',
|
||||||
|
type: 'variable',
|
||||||
|
position: { x: -270, y: 210 },
|
||||||
|
data: { value: 'sss', valueType: 'string' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfg_001',
|
||||||
|
type: 'config',
|
||||||
|
position: { x: 180, y: 330 },
|
||||||
|
data: { configType: 'plantuml', content: '@startuml\nactor Mulis\n@enduml\n', title: 'cfg_001' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rnd_001',
|
||||||
|
type: 'render',
|
||||||
|
position: { x: 780, y: 540 },
|
||||||
|
data: { updateMode: 'manual', runTrigger: 1, lastRunSourceSignature: 'sig1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rnd_002',
|
||||||
|
type: 'render',
|
||||||
|
position: { x: 780, y: 180 },
|
||||||
|
data: { updateMode: 'auto', runTrigger: 2, lastRunSourceSignature: 'sig2' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const edges: AppEdge[] = [
|
||||||
|
{ id: 'xy-edge__var_001out-cfg_001ain', source: 'var_001', target: 'cfg_001', data: { targetType: 'config' } },
|
||||||
|
{ id: 'xy-edge__cfg_001out-rnd_001ain', source: 'cfg_001', target: 'rnd_001', data: { targetType: 'render' } },
|
||||||
|
{ id: 'xy-edge__cfg_001out-rnd_002ain', source: 'cfg_001', target: 'rnd_002', data: { targetType: 'render' } },
|
||||||
|
]
|
||||||
|
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||||||
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'var_001' })
|
||||||
|
|
||||||
|
const state = getCanvasStore()
|
||||||
|
const pathIds = selectPathNodeIds(state)
|
||||||
|
expect(pathIds.has('var_001')).toBe(true)
|
||||||
|
expect(pathIds.has('cfg_001')).toBe(true)
|
||||||
|
expect(pathIds.has('rnd_001')).toBe(true)
|
||||||
|
expect(pathIds.has('rnd_002')).toBe(true)
|
||||||
|
|
||||||
|
// During pulse, all path edges show "updating".
|
||||||
|
expect(selectConnectionStatusForEdge(state, 'var_001', 'cfg_001')).toBe('updating')
|
||||||
|
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_001')).toBe('updating')
|
||||||
|
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_002')).toBe('updating')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ export type GraphSlice = {
|
|||||||
// Path slice (primitive arrays; derived Sets are in selectors)
|
// Path slice (primitive arrays; derived Sets are in selectors)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Pulse: "updating" shows along path for a short time after a trigger; no node start/end. */
|
||||||
export type PathSlice = {
|
export type PathSlice = {
|
||||||
updatingNodeIds: string[]
|
|
||||||
triggerNodeIds: string[]
|
triggerNodeIds: string[]
|
||||||
pausedNodeIds: string[]
|
/** When non-null, path from triggers shows "updating" until this time (ms). Cleared by timer. */
|
||||||
|
pulseEndsAt: number | null
|
||||||
errorNodeIds: string[]
|
errorNodeIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +58,6 @@ export type CanvasCommand =
|
|||||||
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
|
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
|
||||||
| { type: 'path/addTrigger'; payload: string }
|
| { type: 'path/addTrigger'; payload: string }
|
||||||
| { type: 'path/clearTriggers' }
|
| { type: 'path/clearTriggers' }
|
||||||
| { type: 'path/startUpdate'; payload: string }
|
|
||||||
| { type: 'path/endUpdate'; payload: string }
|
|
||||||
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
|
|
||||||
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
||||||
| { type: 'path/clearPathSession' }
|
| { type: 'path/clearPathSession' }
|
||||||
| { type: 'path/clearErrors' }
|
| { type: 'path/clearErrors' }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||||
|
|
||||||
/** Serialize set to a stable string for equality. */
|
/** Serialize set to a stable string for equality. */
|
||||||
function setToStableKey(s: Set<string>): string {
|
function setToStableKey(s: Set<string>): string {
|
||||||
@@ -14,8 +14,7 @@ function setToStableKey(s: Set<string>): string {
|
|||||||
|
|
||||||
export type EdgeLike = { source: string; target: string }
|
export type EdgeLike = { source: string; target: string }
|
||||||
|
|
||||||
/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */
|
const PULSE_MS = 1500
|
||||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
|
||||||
|
|
||||||
export type UseCanvasConnectionPathResult = {
|
export type UseCanvasConnectionPathResult = {
|
||||||
connectionPathUpdatingNodeIds: string[]
|
connectionPathUpdatingNodeIds: string[]
|
||||||
@@ -35,51 +34,13 @@ export type UseCanvasConnectionPathResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
||||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState<string[]>([])
|
|
||||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
||||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState<string[]>([])
|
const [pulseEndsAt, setPulseEndsAt] = useState<number | null>(null)
|
||||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||||
|
|
||||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
|
||||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
|
||||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
|
||||||
|
|
||||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||||
const pathTriggerScheduledRef = useRef(false)
|
const pathTriggerScheduledRef = useRef(false)
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const clearPathUpdateSession = useCallback(() => {
|
|
||||||
setConnectionPathUpdatingNodeIds([])
|
|
||||||
if (connectionPathPausedNodeIdsRef.current.length === 0) {
|
|
||||||
setConnectionPathTriggerNodeIds([])
|
|
||||||
setConnectionPathPausedNodeIds([])
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
|
||||||
const ref = pathUpdateNodeIdsRef.current
|
|
||||||
ref.add(nodeId)
|
|
||||||
if (ref.size === 1) {
|
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
|
||||||
pathUpdateEndTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
|
||||||
const ref = pathUpdateNodeIdsRef.current
|
|
||||||
ref.delete(nodeId)
|
|
||||||
if (ref.size > 0) {
|
|
||||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
|
||||||
pathUpdateEndTimeoutRef.current = null
|
|
||||||
clearPathUpdateSession()
|
|
||||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
|
||||||
}, [clearPathUpdateSession])
|
|
||||||
|
|
||||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||||
pathTriggerBatchRef.current.add(nodeId)
|
pathTriggerBatchRef.current.add(nodeId)
|
||||||
@@ -95,27 +56,31 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
batch.forEach((id) => next.add(id))
|
batch.forEach((id) => next.add(id))
|
||||||
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
||||||
})
|
})
|
||||||
|
setPulseEndsAt(Date.now() + PULSE_MS)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => () => {
|
if (pulseEndsAt == null) return
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
const delay = Math.max(0, pulseEndsAt - Date.now())
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
|
||||||
|
pulseTimerRef.current = setTimeout(() => {
|
||||||
|
pulseTimerRef.current = null
|
||||||
|
setPulseEndsAt(null)
|
||||||
|
setConnectionPathTriggerNodeIds([])
|
||||||
|
}, delay)
|
||||||
|
return () => {
|
||||||
|
if (pulseTimerRef.current != null) {
|
||||||
|
clearTimeout(pulseTimerRef.current)
|
||||||
|
pulseTimerRef.current = null
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[]
|
}, [pulseEndsAt])
|
||||||
)
|
|
||||||
|
|
||||||
const connectionPathNodeIdsRaw = useMemo(
|
const connectionPathNodeIdsRaw = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPathNodeIds(
|
getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined),
|
||||||
edges,
|
[edges, connectionPathTriggerNodeIds]
|
||||||
connectionPathUpdatingNodeIds,
|
|
||||||
connectionPathTriggerNodeIds,
|
|
||||||
connectionPathPausedNodeIds
|
|
||||||
),
|
|
||||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
|
||||||
)
|
)
|
||||||
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
||||||
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
||||||
@@ -128,37 +93,19 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
return connectionPathNodeIdsRaw
|
return connectionPathNodeIdsRaw
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const connectionPathPausedSegmentNodeIdsRaw = useMemo(
|
const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set<string>(), [])
|
||||||
() =>
|
const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT
|
||||||
getPausedSegmentNodeIds(
|
|
||||||
edges,
|
|
||||||
connectionPathNodeIds,
|
|
||||||
connectionPathTriggerNodeIds,
|
|
||||||
connectionPathPausedNodeIds
|
|
||||||
),
|
|
||||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
|
||||||
)
|
|
||||||
const connectionPathPausedSegmentNodeIdsRef = useRef<Set<string>>(
|
|
||||||
connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
)
|
|
||||||
const connectionPathPausedSegmentNodeIdsKeyRef = useRef<string>('')
|
|
||||||
const connectionPathPausedSegmentNodeIds =
|
|
||||||
setToStableKey(connectionPathPausedSegmentNodeIdsRaw) ===
|
|
||||||
connectionPathPausedSegmentNodeIdsKeyRef.current
|
|
||||||
? connectionPathPausedSegmentNodeIdsRef.current
|
|
||||||
: (() => {
|
|
||||||
connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey(
|
|
||||||
connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
)
|
|
||||||
connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
return connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
})()
|
|
||||||
|
|
||||||
const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => {
|
const pulseActive = pulseEndsAt != null
|
||||||
const active = new Set(connectionPathNodeIds)
|
const connectionPathActiveSegmentNodeIdsRaw = useMemo(
|
||||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
() =>
|
||||||
return active
|
getPathToUpdatingSegmentNodeIds(
|
||||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
edges,
|
||||||
|
connectionPathTriggerNodeIds,
|
||||||
|
pulseActive
|
||||||
|
),
|
||||||
|
[edges, connectionPathTriggerNodeIds, pulseActive]
|
||||||
|
)
|
||||||
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
||||||
connectionPathActiveSegmentNodeIdsRaw
|
connectionPathActiveSegmentNodeIdsRaw
|
||||||
)
|
)
|
||||||
@@ -175,13 +122,11 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
return connectionPathActiveSegmentNodeIdsRaw
|
return connectionPathActiveSegmentNodeIdsRaw
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||||
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||||
@@ -192,9 +137,9 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionPathUpdatingNodeIds,
|
connectionPathUpdatingNodeIds: [],
|
||||||
connectionPathTriggerNodeIds,
|
connectionPathTriggerNodeIds,
|
||||||
connectionPathPausedNodeIds,
|
connectionPathPausedNodeIds: [],
|
||||||
connectionPathErrorNodeIds,
|
connectionPathErrorNodeIds,
|
||||||
connectionPathNodeIds,
|
connectionPathNodeIds,
|
||||||
connectionPathPausedSegmentNodeIds,
|
connectionPathPausedSegmentNodeIds,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Connection-path state and callbacks backed by the canvas store.
|
* Connection-path state and callbacks backed by the canvas store.
|
||||||
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
|
* "Updating" is time-bound: when a trigger is added, the path pulses for a short time
|
||||||
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
|
* and then clears. No node start/end reporting.
|
||||||
*
|
|
||||||
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
|
|
||||||
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -16,10 +13,10 @@ import {
|
|||||||
selectPathRoleForNode,
|
selectPathRoleForNode,
|
||||||
type ConnectionPathRole,
|
type ConnectionPathRole,
|
||||||
} from '@/app/canvas/canvasStore.selectors'
|
} from '@/app/canvas/canvasStore.selectors'
|
||||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphologyPath'
|
||||||
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
|
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
|
||||||
|
|
||||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
const EMPTY_PAUSED_SEGMENT = new Set<string>()
|
||||||
|
|
||||||
function edgesAsGraphEdges(
|
function edgesAsGraphEdges(
|
||||||
edges: Array<{ source: string; target: string }>
|
edges: Array<{ source: string; target: string }>
|
||||||
@@ -30,66 +27,63 @@ function edgesAsGraphEdges(
|
|||||||
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
|
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
|
||||||
const path = useCanvasStore((s) => s.path)
|
const path = useCanvasStore((s) => s.path)
|
||||||
const edges = useCanvasStore((s) => s.graph.edges)
|
const edges = useCanvasStore((s) => s.graph.edges)
|
||||||
|
const pulseEndsAtRef = useRef<number | null>(null)
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const pathNodeIds = useMemo(
|
const pathNodeIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPathNodeIds(
|
getPathNodeIds(
|
||||||
edgesAsGraphEdges(edges),
|
edgesAsGraphEdges(edges),
|
||||||
path.updatingNodeIds,
|
[],
|
||||||
path.triggerNodeIds,
|
path.triggerNodeIds,
|
||||||
path.pausedNodeIds
|
undefined
|
||||||
),
|
),
|
||||||
[
|
[edges, path.triggerNodeIds]
|
||||||
edges,
|
|
||||||
path.updatingNodeIds,
|
|
||||||
path.triggerNodeIds,
|
|
||||||
path.pausedNodeIds,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
const pulseActive = path.pulseEndsAt != null
|
||||||
|
const connectionPathActiveSegmentNodeIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPausedSegmentNodeIds(
|
getPathToUpdatingSegmentNodeIds(
|
||||||
edgesAsGraphEdges(edges),
|
edgesAsGraphEdges(edges),
|
||||||
pathNodeIds,
|
|
||||||
path.triggerNodeIds,
|
path.triggerNodeIds,
|
||||||
path.pausedNodeIds
|
pulseActive
|
||||||
),
|
),
|
||||||
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
|
[edges, path.triggerNodeIds, pulseActive]
|
||||||
)
|
)
|
||||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
|
||||||
const active = new Set(pathNodeIds)
|
|
||||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
|
||||||
return active
|
|
||||||
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
|
|
||||||
|
|
||||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
useEffect(() => {
|
||||||
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
|
const endsAt = path.pulseEndsAt
|
||||||
|
if (endsAt == null) {
|
||||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
if (pulseTimerRef.current != null) {
|
||||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
clearTimeout(pulseTimerRef.current)
|
||||||
}, [])
|
pulseTimerRef.current = null
|
||||||
|
}
|
||||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
pulseEndsAtRef.current = null
|
||||||
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
return
|
||||||
}, [])
|
}
|
||||||
|
if (endsAt === pulseEndsAtRef.current) return
|
||||||
|
pulseEndsAtRef.current = endsAt
|
||||||
|
const delay = Math.max(0, endsAt - Date.now())
|
||||||
|
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
|
||||||
|
pulseTimerRef.current = setTimeout(() => {
|
||||||
|
pulseTimerRef.current = null
|
||||||
|
pulseEndsAtRef.current = null
|
||||||
|
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||||
|
}, delay)
|
||||||
|
return () => {
|
||||||
|
if (pulseTimerRef.current != null) {
|
||||||
|
clearTimeout(pulseTimerRef.current)
|
||||||
|
pulseTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [path.pulseEndsAt])
|
||||||
|
|
||||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
dispatchCanvasCommand({
|
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
type: 'path/setPaused',
|
|
||||||
payload: { nodeId, paused: true },
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
|
||||||
dispatchCanvasCommand({
|
|
||||||
type: 'path/setPaused',
|
|
||||||
payload: { nodeId, paused: false },
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||||
dispatchCanvasCommand({
|
dispatchCanvasCommand({
|
||||||
@@ -105,36 +99,16 @@ export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResul
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevUpdatingLengthRef.current
|
|
||||||
const now = path.updatingNodeIds.length
|
|
||||||
prevUpdatingLengthRef.current = now
|
|
||||||
if (prev > 0 && now === 0) {
|
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
|
||||||
}
|
|
||||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
|
||||||
pathUpdateEndTimeoutRef.current = null
|
|
||||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
|
||||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [path.updatingNodeIds.length])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionPathUpdatingNodeIds: path.updatingNodeIds,
|
connectionPathUpdatingNodeIds: [],
|
||||||
connectionPathTriggerNodeIds: path.triggerNodeIds,
|
connectionPathTriggerNodeIds: path.triggerNodeIds,
|
||||||
connectionPathPausedNodeIds: path.pausedNodeIds,
|
connectionPathPausedNodeIds: [],
|
||||||
connectionPathErrorNodeIds: path.errorNodeIds,
|
connectionPathErrorNodeIds: path.errorNodeIds,
|
||||||
connectionPathNodeIds: pathNodeIds,
|
connectionPathNodeIds: pathNodeIds,
|
||||||
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
|
connectionPathPausedSegmentNodeIds: EMPTY_PAUSED_SEGMENT,
|
||||||
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
|
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
|
||||||
startConnectionPathUpdate,
|
startConnectionPathUpdate: () => {},
|
||||||
endConnectionPathUpdate,
|
endConnectionPathUpdate: () => {},
|
||||||
addConnectionPathTrigger,
|
addConnectionPathTrigger,
|
||||||
addConnectionPathPausedNode,
|
addConnectionPathPausedNode,
|
||||||
removeConnectionPathPausedNode,
|
removeConnectionPathPausedNode,
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ function AnimatedEdgeInner({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
||||||
return (
|
return (
|
||||||
prev.id === next.id &&
|
prev.id === next.id &&
|
||||||
@@ -142,3 +141,4 @@ function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)
|
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export function BaseNode({
|
|||||||
"hover:ring-1",
|
"hover:ring-1",
|
||||||
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
|
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
|
||||||
connectionPathRole === "trigger" && "connection-path-trigger",
|
connectionPathRole === "trigger" && "connection-path-trigger",
|
||||||
connectionPathRole === "updating" && "connection-path-updating",
|
|
||||||
connectionPathRole === "on-path" && "connection-path-on-path",
|
connectionPathRole === "on-path" && "connection-path-on-path",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import {
|
import {
|
||||||
useRenderingNodeState,
|
useRenderingNodeState,
|
||||||
@@ -170,13 +169,11 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={state.loading}
|
disabled={
|
||||||
className={cn(
|
state.loading ||
|
||||||
'h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs',
|
(state.effectiveUpdateMode === 'manual' && !state.hasPendingInputs)
|
||||||
state.effectiveUpdateMode === 'manual' &&
|
}
|
||||||
state.hasPendingInputs &&
|
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||||
'border-l-2 border-l-amber-500 bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20'
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
state.incrementRunTrigger()
|
state.incrementRunTrigger()
|
||||||
@@ -184,6 +181,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
title={
|
title={
|
||||||
state.effectiveUpdateMode === 'manual' && state.hasPendingInputs
|
state.effectiveUpdateMode === 'manual' && state.hasPendingInputs
|
||||||
? 'Inputs changed — click to render'
|
? 'Inputs changed — click to render'
|
||||||
|
: state.effectiveUpdateMode === 'manual' && !state.hasPendingInputs
|
||||||
|
? 'No new data to render'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -200,12 +199,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className="h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal"
|
||||||
'h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal',
|
|
||||||
state.effectiveUpdateMode === 'manual' &&
|
|
||||||
state.hasPendingInputs &&
|
|
||||||
'bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20'
|
|
||||||
)}
|
|
||||||
aria-label="Update mode"
|
aria-label="Update mode"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ const DEFAULT_VIEWPORT_WIDTH = 1200
|
|||||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||||
const RENDER_DEBOUNCE_MS = 250
|
const RENDER_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/paused/error) stays in sync. */
|
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
|
||||||
export type RenderingNodeLifecycle = {
|
export type RenderingNodeLifecycle = {
|
||||||
updating: boolean
|
updating: boolean
|
||||||
error: boolean
|
error: boolean
|
||||||
paused: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderingNodeState = {
|
export type RenderingNodeState = {
|
||||||
@@ -102,14 +101,21 @@ export function useRenderingNodeState(
|
|||||||
data: RenderingNodeData | undefined
|
data: RenderingNodeData | undefined
|
||||||
): RenderingNodeState {
|
): RenderingNodeState {
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes: contextNodes,
|
||||||
edges,
|
edges: contextEdges,
|
||||||
setNodes,
|
setNodes,
|
||||||
setEdges,
|
setEdges,
|
||||||
sourceIds: incomingIds,
|
sourceIds: incomingIds,
|
||||||
updateData,
|
updateData,
|
||||||
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||||
|
|
||||||
|
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
||||||
|
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
||||||
|
const storeNodes = useCanvasStore((s) => s.graph.nodes)
|
||||||
|
const storeEdges = useCanvasStore((s) => s.graph.edges)
|
||||||
|
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
||||||
|
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
||||||
|
|
||||||
const nodesEdgesRef = useRef({ nodes, edges })
|
const nodesEdgesRef = useRef({ nodes, edges })
|
||||||
nodesEdgesRef.current = { nodes, edges }
|
nodesEdgesRef.current = { nodes, edges }
|
||||||
|
|
||||||
@@ -189,8 +195,6 @@ export function useRenderingNodeState(
|
|||||||
const lastManualRunTriggerRef = useRef(0)
|
const lastManualRunTriggerRef = useRef(0)
|
||||||
const manualRunTriggerSyncedRef = useRef(false)
|
const manualRunTriggerSyncedRef = useRef(false)
|
||||||
|
|
||||||
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
|
|
||||||
|
|
||||||
const updateDataRef = useRef(updateData)
|
const updateDataRef = useRef(updateData)
|
||||||
updateDataRef.current = updateData
|
updateDataRef.current = updateData
|
||||||
const setNodesRef = useRef(setNodes)
|
const setNodesRef = useRef(setNodes)
|
||||||
@@ -200,13 +204,12 @@ export function useRenderingNodeState(
|
|||||||
const hasPendingInputs =
|
const hasPendingInputs =
|
||||||
effectiveUpdateMode === 'manual' &&
|
effectiveUpdateMode === 'manual' &&
|
||||||
!loading &&
|
!loading &&
|
||||||
triggerNodeIds.length > 0 &&
|
|
||||||
incomingIds.length > 0 &&
|
incomingIds.length > 0 &&
|
||||||
sourceSignature !== lastRunSourceSignature
|
sourceSignature !== lastRunSourceSignature
|
||||||
|
|
||||||
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
||||||
() => ({ updating: loading, error: error != null, paused: hasPendingInputs }),
|
() => ({ updating: loading, error: error != null }),
|
||||||
[loading, error, hasPendingInputs]
|
[loading, error]
|
||||||
)
|
)
|
||||||
useSyncConnectionStatus(id, lifecycle)
|
useSyncConnectionStatus(id, lifecycle)
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Connection status: visual state of an edge (color/class).
|
* Connection status: visual state of an edge (color/class).
|
||||||
* Priority when multiple apply: error > paused > updating > default.
|
* Priority: error > updating > default.
|
||||||
*
|
|
||||||
* State flow: nodes report lifecycle via useSyncConnectionStatus() (nodeLifecycle.ts) → FlowContext
|
|
||||||
* holds the sets → edges pass those sets into getConnectionStatus() here. See lib/graph/state.ts.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
export type ConnectionStatus = 'default' | 'updating' | 'error'
|
||||||
|
|
||||||
export type ConnectionStatusInputs = {
|
export type ConnectionStatusInputs = {
|
||||||
source: string
|
source: string
|
||||||
target: string
|
target: string
|
||||||
pathNodeIds: Set<string>
|
pathNodeIds: Set<string>
|
||||||
pausedSegmentNodeIds: Set<string>
|
|
||||||
activeSegmentNodeIds: Set<string>
|
activeSegmentNodeIds: Set<string>
|
||||||
errorTargetNodeIds: Set<string>
|
errorTargetNodeIds: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the single connection status for an edge (priority: error > paused > updating > default).
|
* Compute the single connection status for an edge (priority: error > updating > default).
|
||||||
* Use in edge components; add new statuses by extending the type and adding a branch here.
|
|
||||||
*/
|
*/
|
||||||
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
||||||
const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
const { target, pathNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
||||||
if (errorTargetNodeIds.has(target)) return 'error'
|
if (errorTargetNodeIds.has(target)) return 'error'
|
||||||
if (
|
|
||||||
pathNodeIds.has(source) &&
|
|
||||||
pathNodeIds.has(target) &&
|
|
||||||
pausedSegmentNodeIds.has(source) &&
|
|
||||||
pausedSegmentNodeIds.has(target)
|
|
||||||
)
|
|
||||||
return 'paused'
|
|
||||||
if (
|
if (
|
||||||
pathNodeIds.has(source) &&
|
pathNodeIds.has(source) &&
|
||||||
pathNodeIds.has(target) &&
|
pathNodeIds.has(target) &&
|
||||||
@@ -45,6 +33,5 @@ export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionS
|
|||||||
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
||||||
default: '',
|
default: '',
|
||||||
updating: 'animated-edge-path--updating',
|
updating: 'animated-edge-path--updating',
|
||||||
paused: 'animated-edge-path--paused',
|
|
||||||
error: 'animated-edge-path--error',
|
error: 'animated-edge-path--error',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import type { AppNode, AppEdge } from './nodeTypes'
|
|||||||
|
|
||||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||||
|
|
||||||
/** Role of a node in the current connection path update. */
|
/** Role of a node in the current connection path. */
|
||||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
|
export type ConnectionPathRole = 'trigger' | 'on-path'
|
||||||
|
|
||||||
export type FlowActions = {
|
export type FlowActions = {
|
||||||
pasteAtViewportCenter: () => void
|
pasteAtViewportCenter: () => void
|
||||||
@@ -108,11 +108,9 @@ export function useConnectionPathRole(nodeId: string | undefined): ConnectionPat
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!nodeId) return null
|
if (!nodeId) return null
|
||||||
const triggers = ctx?.connectionPathTriggerNodeIds
|
const triggers = ctx?.connectionPathTriggerNodeIds
|
||||||
const updating = ctx?.connectionPathUpdatingNodeIds
|
|
||||||
const path = ctx?.connectionPathNodeIds
|
const path = ctx?.connectionPathNodeIds
|
||||||
if (!path?.has(nodeId)) return null
|
if (!path?.has(nodeId)) return null
|
||||||
if (triggers?.includes(nodeId)) return 'trigger'
|
if (triggers?.includes(nodeId)) return 'trigger'
|
||||||
if (updating?.includes(nodeId)) return 'updating'
|
|
||||||
return 'on-path'
|
return 'on-path'
|
||||||
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
|
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathNodeIds])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
|
* @deprecated Use graphologyPath.ts instead. Path derivation now uses graphology
|
||||||
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
|
* (DirectedGraph + BFS) for traversal. This file re-exports from graphologyPath
|
||||||
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
|
* for backward compatibility and will be removed in a future version.
|
||||||
|
*
|
||||||
|
* @see graphologyPath.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GraphEdge = { source: string; target: string }
|
export type { GraphEdge } from './graphologyPath'
|
||||||
|
export {
|
||||||
/** Nodes reachable from seedIds by following edges forward (source → target). */
|
getPathNodeIds,
|
||||||
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
getPathToUpdatingSegmentNodeIds,
|
||||||
const out = new Set<string>(seedIds)
|
clearGraphologyPathCache,
|
||||||
let added = true
|
} from './graphologyPath'
|
||||||
while (added) {
|
|
||||||
added = false
|
|
||||||
for (const e of edges) {
|
|
||||||
if (out.has(e.source) && !out.has(e.target)) {
|
|
||||||
out.add(e.target)
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nodes that can reach any seed by following edges backward (target → source). */
|
|
||||||
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
|
||||||
const out = new Set<string>(seedIds)
|
|
||||||
let added = true
|
|
||||||
while (added) {
|
|
||||||
added = false
|
|
||||||
for (const e of edges) {
|
|
||||||
if (out.has(e.target) && !out.has(e.source)) {
|
|
||||||
out.add(e.source)
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All node ids that lie on the path of an update.
|
|
||||||
* - If updatingNodeIds non-empty: path = downstream(trigger) ∩ (upstream(updating) ∪ downstream(updating))
|
|
||||||
* so we include config→agent→rendering when agent is running.
|
|
||||||
* - Else if triggerNodeIds and pausedNodeIds non-empty: path = downstream(trigger) ∩ upstream(paused)
|
|
||||||
* so we show yellow (config→agent) when config changed and agent is on hold.
|
|
||||||
* An edge should show color iff both its source and target are in this set.
|
|
||||||
*/
|
|
||||||
export function getPathNodeIds(
|
|
||||||
edges: GraphEdge[],
|
|
||||||
updatingNodeIds: string[],
|
|
||||||
triggerNodeIds?: string[],
|
|
||||||
pausedNodeIds?: string[]
|
|
||||||
): Set<string> {
|
|
||||||
const hasUpdating = updatingNodeIds.length > 0
|
|
||||||
const hasPausedPath =
|
|
||||||
pausedNodeIds != null &&
|
|
||||||
pausedNodeIds.length > 0 &&
|
|
||||||
triggerNodeIds != null &&
|
|
||||||
triggerNodeIds.length > 0
|
|
||||||
|
|
||||||
if (hasUpdating && triggerNodeIds != null && triggerNodeIds.length > 0) {
|
|
||||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
|
|
||||||
const upstreamOfUpdating = getUpstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const downstreamOfUpdating = getDownstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const path = new Set<string>()
|
|
||||||
downstreamOfTrigger.forEach((id) => {
|
|
||||||
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
|
||||||
})
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPausedPath && !hasUpdating) {
|
|
||||||
const upstream = getUpstreamNodeIds(edges, pausedNodeIds!)
|
|
||||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds!)
|
|
||||||
const path = new Set<string>()
|
|
||||||
upstream.forEach((id) => {
|
|
||||||
if (downstreamOfTrigger.has(id)) path.add(id)
|
|
||||||
})
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUpdating) {
|
|
||||||
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const path = new Set<string>(upstream)
|
|
||||||
downstream.forEach((id) => path.add(id))
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path nodes from triggers up to and including the first paused node (e.g. Renderer waiting for Run).
|
|
||||||
* Used to color those edges yellow; rest of path stays blue.
|
|
||||||
*/
|
|
||||||
export function getPausedSegmentNodeIds(
|
|
||||||
edges: GraphEdge[],
|
|
||||||
pathNodeIds: Set<string>,
|
|
||||||
triggerNodeIds: string[],
|
|
||||||
pausedNodeIds: string[]
|
|
||||||
): Set<string> {
|
|
||||||
if (pausedNodeIds.length === 0 || triggerNodeIds.length === 0) return new Set()
|
|
||||||
const pausedSet = new Set(pausedNodeIds)
|
|
||||||
const seeds = triggerNodeIds.filter((id) => pathNodeIds.has(id))
|
|
||||||
if (seeds.length === 0) return new Set()
|
|
||||||
const out = new Set<string>(seeds)
|
|
||||||
const frontier: string[] = [...seeds]
|
|
||||||
const visited = new Set<string>(seeds)
|
|
||||||
while (frontier.length > 0) {
|
|
||||||
const n = frontier.shift()!
|
|
||||||
if (pausedSet.has(n)) continue
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.source !== n || !pathNodeIds.has(e.target) || visited.has(e.target)) continue
|
|
||||||
visited.add(e.target)
|
|
||||||
out.add(e.target)
|
|
||||||
if (pausedSet.has(e.target)) continue
|
|
||||||
frontier.push(e.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Path derivation for connection status using graphology.
|
||||||
|
* Builds a DirectedGraph from canvas edges (with optional node attributes),
|
||||||
|
* uses BFS (outbound/inbound) to compute path sets for propagation animation.
|
||||||
|
*
|
||||||
|
* @see https://graphology.github.io/
|
||||||
|
* @see https://graphology.github.io/standard-library/traversal.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import DirectedGraph from 'graphology'
|
||||||
|
import { bfsFromNode } from 'graphology-traversal'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GraphEdge = { source: string; target: string; id?: string }
|
||||||
|
|
||||||
|
/** Optional attributes stored on graphology nodes (e.g. for sink detection). */
|
||||||
|
export type GraphologyNodeAttributes = {
|
||||||
|
nodeType?: string
|
||||||
|
updateMode?: 'auto' | 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional: pass when building so graph has node type/updateMode for future use. */
|
||||||
|
export type NodeAttributesMap = Record<string, GraphologyNodeAttributes>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graph build (with cache)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedEdgesRef: GraphEdge[] | null = null
|
||||||
|
let cachedGraph: DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> | null =
|
||||||
|
null
|
||||||
|
|
||||||
|
function buildGraph(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> {
|
||||||
|
if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph
|
||||||
|
|
||||||
|
const graph = new DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }>()
|
||||||
|
const nodeIds = new Set<string>()
|
||||||
|
for (const e of edges) {
|
||||||
|
nodeIds.add(e.source)
|
||||||
|
nodeIds.add(e.target)
|
||||||
|
}
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
const attrs: GraphologyNodeAttributes = { ...nodeAttributes?.[id] }
|
||||||
|
graph.mergeNode(id, attrs)
|
||||||
|
}
|
||||||
|
for (const e of edges) {
|
||||||
|
const key = e.id ?? `${e.source}->${e.target}`
|
||||||
|
if (!graph.hasEdge(e.source, e.target)) {
|
||||||
|
graph.addEdgeWithKey(key, e.source, e.target, {
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
edgeId: e.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedEdgesRef = edges
|
||||||
|
cachedGraph = graph
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call when graph structure changes from outside (e.g. store reset) to clear cache. */
|
||||||
|
export function clearGraphologyPathCache(): void {
|
||||||
|
cachedEdgesRef = null
|
||||||
|
cachedGraph = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Traversal helpers (BFS via graphology-traversal)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Outbound BFS: collect all node ids reachable from seeds following edges forward (source → target). */
|
||||||
|
function getDownstreamNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
seedIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const id of seedIds) {
|
||||||
|
if (!graph.hasNode(id)) continue
|
||||||
|
bfsFromNode(
|
||||||
|
graph,
|
||||||
|
id,
|
||||||
|
(node) => {
|
||||||
|
out.add(node)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
{ mode: 'outbound' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound BFS: collect all node ids that can reach any seed (following edges backward). */
|
||||||
|
function getUpstreamNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
seedIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const id of seedIds) {
|
||||||
|
if (!graph.hasNode(id)) continue
|
||||||
|
bfsFromNode(
|
||||||
|
graph,
|
||||||
|
id,
|
||||||
|
(node) => {
|
||||||
|
out.add(node)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
{ mode: 'inbound' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path-to-updating / path-to-paused (same semantics as graphPath.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getPathToUpdatingNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
updatingNodeIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const path = new Set<string>()
|
||||||
|
downstreamOfTrigger.forEach((id) => {
|
||||||
|
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathToPausedNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
pausedNodeIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds)
|
||||||
|
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
const path = new Set<string>()
|
||||||
|
upstreamOfPaused.forEach((id) => {
|
||||||
|
if (downstreamOfTrigger.has(id)) path.add(id)
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API (same signatures as graphPath.ts for drop-in replacement)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All node ids on the path of an update (downstream of trigger, or path to updating nodes).
|
||||||
|
*/
|
||||||
|
export function getPathNodeIds(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
updatingNodeIds: string[],
|
||||||
|
triggerNodeIds?: string[],
|
||||||
|
_pausedNodeIds?: string[],
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): Set<string> {
|
||||||
|
const hasUpdating = updatingNodeIds.length > 0
|
||||||
|
const hasTrigger = triggerNodeIds != null && triggerNodeIds.length > 0
|
||||||
|
const graph = buildGraph(edges, nodeAttributes)
|
||||||
|
|
||||||
|
if (hasTrigger && hasUpdating) {
|
||||||
|
return getPathToUpdatingNodeIds(graph, triggerNodeIds!, updatingNodeIds)
|
||||||
|
}
|
||||||
|
if (hasUpdating) {
|
||||||
|
const upstream = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const downstream = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const path = new Set<string>(upstream)
|
||||||
|
downstream.forEach((id) => path.add(id))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTrigger) {
|
||||||
|
return getDownstreamNodeIds(graph, triggerNodeIds!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path nodes that show "updating" during the time-bound pulse.
|
||||||
|
* When pulseActive is true, returns downstream(trigger); otherwise empty.
|
||||||
|
*/
|
||||||
|
export function getPathToUpdatingSegmentNodeIds(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
pulseActive: boolean,
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): Set<string> {
|
||||||
|
if (!pulseActive || triggerNodeIds.length === 0) return new Set()
|
||||||
|
const graph = buildGraph(edges, nodeAttributes)
|
||||||
|
return getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
}
|
||||||
@@ -1,85 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* Node lifecycle: contract that nodes implement so the graph can show the right
|
* Node lifecycle: nodes report error state so the graph can show the right
|
||||||
* connection status (edge colors) and path animation.
|
* connection status (edge colors). "Updating" is time-bound from triggers, not per-node.
|
||||||
*
|
*
|
||||||
* ## State flow
|
* Priority for edge status: error > updating (pulse) > default.
|
||||||
*
|
|
||||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
|
||||||
* This hook dispatches to the canvas store; edges read path state via selectors.
|
|
||||||
*
|
|
||||||
* ## Lifecycle phases (conceptual)
|
|
||||||
*
|
|
||||||
* - **Idle** – Node is not on an active path; edges use default style.
|
|
||||||
* - **Trigger** – Node's output changed; path is computed from triggers + updating nodes.
|
|
||||||
* - **Updating** – Node is doing async work. Report `updating: true` → false. Path edges show blue.
|
|
||||||
* - **Paused** – Node is on hold (e.g. manual mode waiting for Run). Report `paused: true` → false. Segment shows yellow.
|
|
||||||
* - **Error** – Node has an error. Report `error: true` → false. Incoming edges show red.
|
|
||||||
*
|
|
||||||
* Priority for edge status: error > paused > updating > default.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
|
|
||||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'on-path' | 'error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State that drives connection status for this node.
|
* State that drives connection status for this node.
|
||||||
* Pass the current values from your node; the hook syncs them to the canvas store.
|
* Only error is synced to the path; updating is shown via time-bound pulse from triggers.
|
||||||
*/
|
*/
|
||||||
export type NodeConnectionStatusState = {
|
export type NodeConnectionStatusState = {
|
||||||
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
/** Ignored for path; kept for API compatibility (e.g. loading spinner). */
|
||||||
updating?: boolean
|
updating?: boolean
|
||||||
/** Node has an error. Incoming edges to this node show red. */
|
/** Node has an error. Incoming edges to this node show red. */
|
||||||
error?: boolean
|
error?: boolean
|
||||||
/** Node is on hold (e.g. agent waiting for Run). Path edges in paused segment show yellow. */
|
|
||||||
paused?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
|
* Syncs this node's error state to the canvas store so connection status (edge colors)
|
||||||
* and path animation are correct. Call once per node with the current updating/error/paused
|
* is correct. Call once per node: useSyncConnectionStatus(id, { error: !!error }).
|
||||||
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
|
|
||||||
*
|
|
||||||
* Use in any node that can be updating, in error, or paused:
|
|
||||||
*
|
|
||||||
* const [loading, setLoading] = useState(false)
|
|
||||||
* const [error, setError] = useState(null)
|
|
||||||
* const hasPendingInputs = ...
|
|
||||||
* useSyncConnectionStatus(id, { updating: loading, error: !!error, paused: hasPendingInputs })
|
|
||||||
*/
|
*/
|
||||||
export function useSyncConnectionStatus(
|
export function useSyncConnectionStatus(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
state: NodeConnectionStatusState
|
state: NodeConnectionStatusState
|
||||||
): void {
|
): void {
|
||||||
const { updating, error, paused } = state
|
const { error } = state
|
||||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
const prevRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevRef.current
|
|
||||||
const nowUpdating = Boolean(updating)
|
|
||||||
const nowError = Boolean(error)
|
const nowError = Boolean(error)
|
||||||
const nowPaused = Boolean(paused)
|
if (prevRef.current !== nowError) {
|
||||||
|
|
||||||
if (prev.updating !== nowUpdating) {
|
|
||||||
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
|
||||||
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
|
||||||
prev.updating = nowUpdating
|
|
||||||
}
|
|
||||||
if (prev.error !== nowError) {
|
|
||||||
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||||
prev.error = nowError
|
prevRef.current = nowError
|
||||||
}
|
}
|
||||||
if (prev.paused !== nowPaused) {
|
|
||||||
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
|
||||||
prev.paused = nowPaused
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
if (prevRef.current) {
|
||||||
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||||
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
prevRef.current = false
|
||||||
prevRef.current = { updating: false, error: false, paused: false }
|
|
||||||
}
|
}
|
||||||
}, [nodeId, updating, error, paused])
|
}
|
||||||
|
}, [nodeId, error])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user