Files
windmill/frontend/src/lib/components/graph/flowStructure.ts
T
GuilhemandClaude Opus 4.6 81eb446eee feat: flow group nodes with collapsible groups (#8075)
* feat: add flow group nodes core infrastructure

Add group data model (start_id/end_id boundary pairs), GroupEditor for
CRUD operations, groupDetectionUtils for membership computation and
validation, GroupedModulesProxy for reactive sync, and compound layout
support. Update openflow.openapi.yaml with group schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add group UI components and rendering

Add GroupOverlay with bounding box and z-ordering, GroupHeader with
StepCountTab and ellipsis menu, GroupNodeCard, GroupNoteArea for inline
markdown notes, CollapsedGroupNode/CollapsedSubflowNode for collapsed
rendering, GroupEndNode/GroupHeadNode boundary markers, and group
actions in NodeContextMenu and SelectionBoundingBox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: integrate groups into flow graph, builder, and existing components

Wire group support into FlowGraphV2 (overlays, collapsed rendering,
group-aware layout), graphBuilder (GroupedModule tree, container
collapse/expand, group boundary nodes), BaseEdge (drop targets for
group operations), ModuleNode (collapsed container rendering), and
flow map components (schema item grouping). Remove SubflowBound in
favor of CollapsedSubflowNode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove banned $bindable(default) pattern and dead ternary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: decouple collapse state from grouped module tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: pass groups prop to FlowGraphV2 and use GroupDisplayState via graphContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove group membership system, compute nesting depth from visual bounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify GroupOverlay bounds, remove unused headerY and showNotes prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: populate innerNodeIds for expanded subflow overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove expanded subflow overlay feature for separate PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: flatten groups in getContainerModules to prevent crash on collapsed containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add drag-to-move support for group nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: derive group boundaries from expanded membership to prevent splitting existing groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: catch group validation errors and display as flow graph alert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for group validation in buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject virtual nodes (Input, Result, Trigger) from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add virtual node rejection tests for buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude preprocessor and failure module from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable Create group button when preprocessor is selected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject selection entirely when it contains excluded nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove unnecessary excludeIds from buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove debug console.log from FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use cross-browser CSS grid trick for group summary input auto-sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide group boundary edges and reformat GroupNoteArea

Hide edges between group header and first node, and between last node
and group-end, keeping them in the DOM but visually hidden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: stop FlowGraphV2 from reading groups via groupEditorContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show module previews with status, selection, and suspend popover in collapsed groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract collapsible implicit containers to separate branch

Remove collapse/expand functionality for implicit containers (forloops,
while loops, branches) from this branch. Backed up as
collapsible-implicit-containers-backup for later rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use original reactive modules for graph node data instead of proxy snapshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent node loss when moving into forloop inside a group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace GroupedModule proxy with structure-only FlowStructureNode tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use "group-" prefix for group IDs instead of "note-"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update group boundaries when renaming a module ID

When a module at a group boundary (start_id or end_id) is renamed,
the group definitions now get updated before the reactive rebuild,
preventing stale references that would break the flow structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update graph layout when removing a group note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add opaque background behind test run button to prevent see-through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect and reject duplicate group IDs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify group creation validation with early marker normalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use $state.raw in MiniFlowGraph to avoid xyflow performance warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address code review feedback

- Revert backend traverse_modules change (not part of this feature)
- Use Map for node lookup in GroupOverlay (O(1) vs O(n) per group)
- Extract computeNodeExtraSpace to nodeExtraSpace.ts for testability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address PR review feedback

- Compute group depths from structure tree O(n) instead of O(n²) bounds comparison
- Remove unnecessary $derived(groups) in GroupOverlay
- Remove unused collapsed field from container types in OpenAPI spec
- Use NODE.width constant in GroupNodeCard instead of hardcoded 275px
- Add comment explaining intentional stale preservation in rebuild()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve flow groups during dependency job re-serialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve Svelte state_referenced_locally warnings in GroupHeader and FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show subflow groups when expanding a subflow in the graph

- Store both modules and groups when expanding a subflow
- Pass groups to buildStructureTree so group nodes render
- Include subflow groups in overlay rendering and collapse tracking
- Clone modules for prefix rewriting to avoid state_unsafe_mutation
- Register expanded subflow modules in moduleMap before prefix rewriting
- Disable group editing in expanded subflows and read-only views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore accidentally removed code from main

- Restore subflowBound selection handling in selectionUtils
- Restore comments in SelectionBoundingBox
- Restore deletable={false} in FirstStepInputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove redundant adjacency check from MoveManager

The disableMoveIds check already prevents all invalid drop targets,
making the adjacencySourceId/adjacencyTargetId fields unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after OpenAPI schema change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate cli skills after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include groups in view_graph localStorage state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: centralize canCreateGroup and replace group note with group creation

- Add canCreateGroup StateStore to GroupEditorContext, computed in FlowGraphV2
- Replace "Create group note" with "Create group" in FlowSelectionPanel
- Remove "Add note" from selection bounding box dropdown
- Remove unused NodeContextMenu component
- Wire createGroup through FlowModuleSchemaMap → FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject groups spanning parallel branches and surface ill-formed group errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure modules appears before groups in YAML export

Svelte 5's $state proxy registers groups as a tracked property before
it's explicitly set, causing it to appear before modules in Object.keys
iteration. Reorder the value object at export time for readable YAML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address second round of PR review feedback

- Add comment explaining duplicateMultiple bypasses structure tree
- Add warning log for inverted ranges in computeGroupModuleIds
- Use NODE.width constant in CollapsedGroupNode instead of hardcoded 275px
- Simplify redundant condition in getGroupsEmptiedBy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove stored group ID, derive ephemeral key from start_id:end_id

Groups no longer store an `id` field. Instead, a `groupKey(g)` helper
derives an ephemeral key from `${start_id}:${end_id}` at read time.
This simplifies the schema while preserving all runtime functionality.

When boundaries shift (module deletion), runtime state (collapse,
note heights) is remapped to the new key via GroupDisplayState.remapGroupKey.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add note button, save/cancel hints, and rename collapsed_by_default to autocollapse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: propagate selection from collapsed group badges to external listeners

Pass eventHandlers to GroupModuleIcons so clicking a module badge
calls both selectionManager.selectId (visual highlight) and
eventHandlers.select (side panel propagation via onSelect).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide In/Out popovers and actions during click-to-move

Replace isDragging with isMoving derived that covers both drag-move
and click-move states, disabling popovers, delete button, and test
run button during any move operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:47:33 +00:00

499 lines
15 KiB
TypeScript

import type { FlowModule } from '$lib/gen'
import type { FlowGroup, GraphGroup } from './groupEditor.svelte'
import { getContainerInnerArrays } from './groupEditor.svelte'
import { VIRTUAL_NODE_IDS } from './groupDetectionUtils'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall'
export type StructureBranch = {
label?: string
children: FlowStructureNode[]
}
export type FlowStructureNode = {
/** FlowModule.id for modules, groupKey(g) for groups */
id: string
kind: 'leaf' | 'group' | ContainerKind
/** Only present when kind === 'group' */
group?: FlowGroup
/** Only present when kind === 'group' — flat module IDs for step count */
moduleIds?: string[]
/** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */
branches: StructureBranch[]
}
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
// Building the structure tree
// ---------------------------------------------------------------------------
export function buildStructureTree(
modules: FlowModule[],
groups: GraphGroup[]
): FlowStructureNode[] {
const { items, consumed } = buildStructureTreeRecurse(modules, groups)
const unconsumed = groups.filter((g) => !consumed.has(g.id))
if (unconsumed.length > 0) {
throw new Error(
`Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` +
`their start/end nodes do not belong to the same branch`
)
}
return items
}
export function moduleToStructureNode(mod: FlowModule): FlowStructureNode {
const innerArrays = getContainerInnerArrays(mod)
if (innerArrays.length === 0) {
return { id: mod.id, kind: 'leaf', branches: [] }
}
const kind = (mod.value as any).type as ContainerKind
const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({
label,
children: [] // filled later by recursion
}))
return { id: mod.id, kind, branches }
}
function buildStructureTreeRecurse(
modules: FlowModule[],
groups: GraphGroup[]
): { items: FlowStructureNode[]; consumed: Set<string> } {
if (modules.length === 0) {
return { items: [], consumed: new Set() }
}
const indexMap = new Map<string, number>()
for (let i = 0; i < modules.length; i++) {
indexMap.set(modules[i].id, i)
}
// Reject duplicate group IDs
const seenGroupIds = new Set<string>()
for (const g of groups) {
if (seenGroupIds.has(g.id)) {
throw new Error(`Duplicate group id: '${g.id}'`)
}
seenGroupIds.add(g.id)
}
// Reject groups referencing virtual nodes
for (const g of groups) {
if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) {
throw new Error(
`Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger`
)
}
}
// Partition: groups for this level vs rest
const levelGroups: GraphGroup[] = []
const otherGroups: GraphGroup[] = []
for (const g of groups) {
if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) {
const s = indexMap.get(g.start_id)!
const e = indexMap.get(g.end_id)!
if (s > e) {
throw new Error(
`Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})`
)
}
levelGroups.push(g)
} else {
otherGroups.push(g)
}
}
// Validate no partial overlaps
for (let i = 0; i < levelGroups.length; i++) {
for (let j = i + 1; j < levelGroups.length; j++) {
const a = levelGroups[i]
const b = levelGroups[j]
const aStart = indexMap.get(a.start_id)!
const aEnd = indexMap.get(a.end_id)!
const bStart = indexMap.get(b.start_id)!
const bEnd = indexMap.get(b.end_id)!
if (aEnd < bStart || bEnd < aStart) continue
if (aStart <= bStart && bEnd <= aEnd) continue
if (bStart <= aStart && aEnd <= bEnd) continue
throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`)
}
}
// Build grouped structure for this level
function build(
startIdx: number,
endIdx: number,
availableGroups: GraphGroup[]
): FlowStructureNode[] {
const result: FlowStructureNode[] = []
let i = startIdx
while (i <= endIdx) {
const candidates = availableGroups.filter((g) => {
const gStart = indexMap.get(g.start_id)!
const gEnd = indexMap.get(g.end_id)!
return gStart === i && gEnd <= endIdx
})
candidates.sort((a, b) => {
const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)!
const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)!
return spanB - spanA
})
const group = candidates[0]
if (group) {
const gEnd = indexMap.get(group.end_id)!
const remaining = availableGroups.filter((g) => g.id !== group.id)
const innerNodes = build(i, gEnd, remaining)
const moduleIds: string[] = []
for (let k = i; k <= gEnd; k++) {
moduleIds.push(modules[k].id)
}
result.push({
id: group.id,
kind: 'group',
group: {
summary: group.summary,
note: group.note,
color: group.color,
autocollapse: group.autocollapse,
start_id: group.start_id,
end_id: group.end_id
},
moduleIds,
branches: [{ children: innerNodes }]
})
i = gEnd + 1
} else {
result.push(moduleToStructureNode(modules[i]))
i++
}
}
return result
}
const result = build(0, modules.length - 1, levelGroups)
// Recurse into containers with remaining unconsumed groups
const consumed = new Set(levelGroups.map((g) => g.id))
let remaining = otherGroups
function recurseIntoContainers(items: FlowStructureNode[]): void {
for (const item of items) {
if (item.kind === 'group') {
recurseIntoContainers(item.branches[0].children)
continue
}
if (item.branches.length === 0) continue
// This is a container module — get inner FlowModule arrays and recurse
const modIdx = indexMap.get(item.id)
if (modIdx === undefined) continue
const mod = modules[modIdx]
const innerArrays = getContainerInnerArrays(mod)
for (let bi = 0; bi < innerArrays.length; bi++) {
const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining)
item.branches[bi] = {
label: item.branches[bi]?.label,
children: inner.items
}
for (const id of inner.consumed) consumed.add(id)
remaining = remaining.filter((g) => !inner.consumed.has(g.id))
}
}
}
recurseIntoContainers(result)
return { items: result, consumed }
}
// ---------------------------------------------------------------------------
// Traversal utilities
// ---------------------------------------------------------------------------
/** Generic DFS over the structure tree */
export function dfsStructure(
nodes: FlowStructureNode[],
fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void
): void {
for (const node of nodes) {
fn(node, nodes)
for (const branch of node.branches) {
dfsStructure(branch.children, fn)
}
}
}
/** Flatten to ordered module IDs (groups are transparent) */
export function flattenStructureIds(nodes: FlowStructureNode[]): string[] {
const ids: string[] = []
for (const node of nodes) {
if (node.kind === 'group') {
ids.push(...flattenStructureIds(node.branches[0].children))
} else {
ids.push(node.id)
}
}
return ids
}
/** Collect leaf module IDs recursively (including inside containers) */
export function collectLeafIds(nodes: FlowStructureNode[]): string[] {
const ids: string[] = []
for (const node of nodes) {
if (node.kind === 'group') {
ids.push(...collectLeafIds(node.branches[0].children))
} else {
ids.push(node.id)
for (const branch of node.branches) {
ids.push(...collectLeafIds(branch.children))
}
}
}
return ids
}
// ---------------------------------------------------------------------------
// Finding nodes in the tree
// ---------------------------------------------------------------------------
export type FindResult = { parentChildren: FlowStructureNode[]; index: number }
export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.id === id) return { parentChildren: nodes, index: i }
for (const branch of node.branches) {
const found = findInStructure(branch.children, id)
if (found) return found
}
}
return undefined
}
/**
* Match a structure node against a graph node ID.
* Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X.
*/
export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean {
if (node.id === nodeId) return true
if (node.kind === 'group') {
return (
nodeId === `group:${node.id}` ||
nodeId === `group:${node.id}-end` ||
nodeId === `collapsed-group:${node.id}`
)
}
return false
}
/**
* Find insert index using graph node IDs (handles group:X-end etc.).
* Returns the index OF the matched item (insert before it).
* For group-end nodes, returns index AFTER the group (insert after it).
*/
export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number {
// group-end: insert after the group
if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) {
const groupId = targetNodeId.slice('group:'.length, -'-end'.length)
const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId)
return idx >= 0 ? idx + 1 : items.length
}
// Everything else: insert at the matched item's position
for (let i = 0; i < items.length; i++) {
if (matchStructureNode(items[i], targetNodeId)) return i
}
return items.length
}
// ---------------------------------------------------------------------------
// Deriving groups from the structure tree
// ---------------------------------------------------------------------------
export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] {
const groups: FlowGroup[] = []
for (const node of nodes) {
if (node.kind === 'group' && node.group) {
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length === 0) {
console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`)
continue
}
groups.push({
...node.group,
start_id: flatIds[0],
end_id: flatIds[flatIds.length - 1]
})
// Recurse for nested groups
groups.push(...deriveGroupsFromStructure(node.branches[0].children))
} else {
for (const branch of node.branches) {
groups.push(...deriveGroupsFromStructure(branch.children))
}
}
}
return groups
}
// ---------------------------------------------------------------------------
// Syncing structure back to FlowModule[]
// ---------------------------------------------------------------------------
/**
* Reconstruct a FlowModule[] from the structure tree, looking up originals
* from moduleMap and patching container inner arrays to match the tree ordering.
*/
export function applyStructureToModules(
nodes: FlowStructureNode[],
moduleMap: Map<string, FlowModule>
): FlowModule[] {
const result: FlowModule[] = []
for (const node of nodes) {
if (node.kind === 'group') {
// Groups are transparent — splice their children into this level
result.push(...applyStructureToModules(node.branches[0].children, moduleMap))
} else {
const mod = moduleMap.get(node.id)
if (!mod) continue
// Patch container inner arrays
if (node.branches.length > 0) {
const innerArrays = getContainerInnerArrays(mod)
for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) {
innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap))
}
}
result.push(mod)
}
}
return result
}
// ---------------------------------------------------------------------------
// Empty groups cleanup
// ---------------------------------------------------------------------------
/**
* Walk the tree, remove group nodes that have no leaf modules, and return
* the removed groups. Mutates the input array in-place.
* Recurses depth-first so inner groups are cleaned before checking outer ones.
*/
export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const removed: FlowGroup[] = []
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (node.kind === 'group' && node.group) {
// Recurse first — inner groups may become empty too
removed.push(...removeEmptyGroups(node.branches[0].children))
if (flattenStructureIds(node.branches[0].children).length === 0) {
removed.push(node.group)
nodes.splice(i, 1)
}
} else {
for (const branch of node.branches) {
removed.push(...removeEmptyGroups(branch.children))
}
}
}
return removed
}
/** Walk the structure tree to compute nesting depth for each group (O(n)). */
export function computeGroupDepths(tree: FlowStructureNode[]): Record<string, number> {
const depths: Record<string, number> = {}
function walk(nodes: FlowStructureNode[], groupDepth: number): void {
for (const node of nodes) {
if (node.kind === 'group') {
depths[node.id] = groupDepth
for (const branch of node.branches) {
walk(branch.children, groupDepth + 1)
}
} else {
for (const branch of node.branches) {
walk(branch.children, groupDepth)
}
}
}
}
walk(tree, 0)
return depths
}
/**
* Find duplicate groups in the structure tree (same start_id:end_id after mutation).
* Returns the groups that should be removed (keeps the first, removes subsequent duplicates).
*/
export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const duplicates: FlowGroup[] = []
const seen = new Set<string>()
function walk(items: FlowStructureNode[]): void {
for (const node of items) {
if (node.kind === 'group' && node.group) {
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length > 0) {
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
if (seen.has(key)) {
duplicates.push(node.group)
} else {
seen.add(key)
}
}
walk(node.branches[0].children)
} else {
for (const branch of node.branches) {
walk(branch.children)
}
}
}
}
walk(nodes)
return duplicates
}
/** Remove duplicate groups from the structure tree (keeps first occurrence). */
export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const removed: FlowGroup[] = []
const seen = new Set<string>()
function walk(items: FlowStructureNode[]): void {
for (let i = items.length - 1; i >= 0; i--) {
const node = items[i]
if (node.kind === 'group' && node.group) {
walk(node.branches[0].children)
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length > 0) {
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
if (seen.has(key)) {
// Replace group node with its children (ungroup)
removed.push(node.group)
items.splice(i, 1, ...node.branches[0].children)
} else {
seen.add(key)
}
}
} else {
for (const branch of node.branches) {
walk(branch.children)
}
}
}
}
walk(nodes)
return removed
}