mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
* 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>
499 lines
15 KiB
TypeScript
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
|
|
}
|