Compare commits

...

4 Commits

Author SHA1 Message Date
centdix
90782336c8 remove rust plugin 2026-03-02 14:23:31 +01:00
centdix
b121f4388b docs: move autonomous-mode reference to system prompt (#8173)
* docs: move autonomous-mode reference from CLAUDE.md to system prompt

Remove the autonomous-mode.md bullet from CLAUDE.md and instead reference
it via the workmux system prompt, matching the workmux-web pattern. Also
remove the duplicated "Dev Environment (tmux)" section from
autonomous-mode.md since that info is already in the system prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add autonomous-mode.md reference to wmdev sandbox system prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:43:19 +00:00
HugoCasa
5ebaa43aa1 internal(workmux): allow cloning main db using WM_CLONE_DB or --clone-db 2026-03-02 11:18:36 +01:00
Guilhem
7a5e487878 feat(frontend): add drag-and-drop node movement in flow editor (#8076)
* feat: add drag-and-drop node movement in flow editor

Replace the 2-step click-based move with drag-and-drop: grab a node's
Move icon, drag it near an insert point, see a visual drop indicator,
and drop to move. Click-based move is preserved as fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: hide insert buttons on edges during drag-and-drop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: unify drop zone and legacy move target styles

Use consistent dot indicator for both drag-and-drop and click-based
move targets. Use text-accent theming, hide insert buttons during drag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: render real SvelteFlow graph in drag ghost for subflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: center drag ghost on the dragged node instead of the whole subflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: pass isSubflow prop through drag system and improve move UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: fade entire subflow during legacy move and drag-and-drop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: use text-secondary for move and drop target indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: improve drag-and-drop visual feedback with proximity cues

Ghost opacity reacts to drop zone proximity (dims when far, brightens
when near). Add move icon badge near cursor that highlights on valid
drop target. Switch hit detection from circular radius to axis-aligned
bounding box matching the node gap dimensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: unify DragGhost to always use MiniFlowGraph

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: scale drag ghost using flow viewport zoom instead of fixed width

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: register drop zone positions from BaseEdge instead of recomputing from node data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: hide node UI clutter during drag and polish drag ghost

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: fade all deeply nested nodes when dragging a subflow

Previously only immediate children of a dragged subflow would fade —
deeply nested nodes (e.g. steps inside a forloop inside a branchall)
stayed at full opacity. Store the full set of dragged node IDs on
DragManager and check set membership instead of single-parent comparison.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename DragManager to MoveManager and eliminate moving prop drilling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: unify subflow node computation for both move modes

Extract getSubflowNodeIds() to moveManager.svelte.ts and populate
draggedNodeIds via a single $effect in DragCoordinator for both legacy
click-to-move and drag-and-drop. Consumers (MapItem, NodeWrapper) now
only check draggedNodeIds set membership instead of dual-checking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: clean up drag-and-drop code review issues

Fix toggle risk in DragCoordinator by using forceSetMoving instead of
the toggle-based setMoving. Remove dead code (DragInfo unused fields,
parentSubflowId, GHOST_ZOOM_FACTOR, debug log), extract duplicated
expressions to $derived variables, and add missing type annotations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: clear click-to-move when drag starts to prevent dual mode activation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: centralize draggedNodeIds cleanup in $effect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: adjust insertion index when moving node forward in same array

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: address PR review feedback for node move feature

- Snapshot drag ghost once at drag start using untrack() to avoid
  recomputing on every nodes/edges change during drag
- Rename setMoving/forceSetMoving to toggleMoving/setMoving for clarity
- Add capture: true to DragCoordinator's Escape handler for consistency
- Rename MOVE_BTN_OFFSET to DRAG_HANDLE_OFFSET with descriptive comment
- Move misplaced import to top of moveManager.svelte.ts
- Replace (n.data as any).offset with typed nodeOffset() helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: register asset/AI node types in MiniFlowGraph for drag ghost

MiniFlowGraph was missing asset, assetsOverflowed, aiTool, and
newAiTool node types, so these nodes rendered as invisible elements
that inflated the drag ghost bounding box. Register them so the
ghost renders all node types correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve relative positions to absolute for xyflow child nodes in drag ghost

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use initialViewport instead of fitView so drag ghost matches flow zoom

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: format BaseEdge.svelte

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: fade asset and AI tool nodes when their parent is being moved

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: include child nodes of edge-matched nodes in subflow ID collection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: hide +Tool button when moving nodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback (listener cleanup, set iteration, dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: position cancel move button on top of node instead of above it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: compute draggedNodeIds eagerly via callback instead of reactive effect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove redundant parentModuleId from NodeWrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: address PR review comments for drag ghost and move manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:06:23 +00:00
35 changed files with 773 additions and 114 deletions

View File

@@ -110,7 +110,6 @@
]
},
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"code-review@claude-plugins-official": true
}

View File

@@ -99,6 +99,8 @@ profiles:
5) Include in PR descriptions using markdown image syntax.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee

View File

@@ -55,7 +55,8 @@ panes:
- Pane 2: frontend (npm run dev)\n\n
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check."
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.\n\n
IMPORTANT: Read docs/autonomous-mode.md before starting any work."
focus: true
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"'
split: horizontal

View File

@@ -12,7 +12,6 @@ Open-source platform for internal tools, workflows, API integrations, background
## Documentation
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
- **Autonomous mode**: `docs/autonomous-mode.md` — when running in bypass/auto permission mode
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code

View File

@@ -39,26 +39,6 @@ Even in bypass mode, **enter plan mode before starting non-trivial work**. Ask a
- Read `docs/validation.md` to know what checks you'll need to run
- Break large features into stages — commit each stage separately
## Dev Environment (tmux)
Backend and frontend are running in tmux panes in the current session:
- **Pane 0**: This pane (Claude agent)
- **Pane 1**: Backend (`cargo watch -x run`)
- **Pane 2**: Frontend (`npm run dev`)
### Checking Logs
```bash
# Backend logs (last 50 lines)
tmux capture-pane -t .1 -p -S -50
# Frontend logs (last 50 lines)
tmux capture-pane -t .2 -p -S -50
```
Backend runs with `cargo watch`, so Rust changes auto-recompile — just check pane 1 logs for errors. No need to run `cargo check` separately during iteration if the backend pane shows a successful build.
## Manual Testing
After code changes compile and type-check, verify the feature works:

View File

@@ -492,7 +492,6 @@
const previewArgsStore = $state({ val: {} })
const scriptEditorDrawer = writable(undefined)
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const stepsInputArgs = new StepsInputArgs()
const selectionManager = new SelectionManager()
@@ -519,7 +518,6 @@
previewArgs: previewArgsStore,
scriptEditorDrawer,
flowEditorDrawer: writable(undefined),
moving,
history,
pathStore: pathStore,
flowStateStore,

View File

@@ -613,7 +613,6 @@
const previewArgsStore = $state({ val: initialArgs })
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const pathStore = writable<string>(pathStoreInit ?? initialPath)
const captureOn = writable<boolean>(false)
@@ -641,7 +640,6 @@
previewArgs: previewArgsStore,
scriptEditorDrawer,
flowEditorDrawer,
moving,
history,
flowStateStore,
flowStore,

View File

@@ -20,7 +20,7 @@
Timer,
Maximize2
} from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { createEventDispatcher, getContext, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import type { FlowEditorContext } from '../types'
import { twMerge } from 'tailwind-merge'
@@ -136,6 +136,7 @@
const flowGraphContext = getGraphContext()
const diffManager = flowGraphContext?.diffManager
const moveManager = flowGraphContext?.moveManager
let pickableIds: Record<string, any> | undefined = $state(undefined)
@@ -185,8 +186,46 @@
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
)
let isDragging = $derived(!!moveManager?.dragging)
// --- Drag handle logic ---
let dragCleanup: (() => void) | undefined
function onMovePointerDown(e: Event) {
const pe = e as PointerEvent
const startX = pe.clientX
const startY = pe.clientY
let didDrag = false
function onMovePointer(me: PointerEvent) {
const dx = me.clientX - startX
const dy = me.clientY - startY
if (!didDrag && Math.sqrt(dx * dx + dy * dy) > 5) {
didDrag = true
if (moveManager && id) {
moveManager.startDrag(id, startX, startY)
}
}
}
function onUp() {
document.removeEventListener('pointermove', onMovePointer)
document.removeEventListener('pointerup', onUp)
dragCleanup = undefined
if (!didDrag) {
dispatch('move')
}
}
document.addEventListener('pointermove', onMovePointer)
document.addEventListener('pointerup', onUp)
dragCleanup = onUp
}
onDestroy(() => dragCleanup?.())
const outputPickerVisible = $derived(
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging
)
const icon_render = $derived(icon)
@@ -474,7 +513,7 @@
{/if}
</div>
{#if deletable}
{#if deletable && !isDragging}
{#if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
@@ -488,12 +527,13 @@
>
<button
class={twMerge(
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary cursor-grab',
hover || selected ? 'block' : '!hidden',
'shadow-md rounded-md',
'group-hover:block'
'group-hover:block',
'touch-none'
)}
onclick={stopPropagation(preventDefault((event) => dispatch('move')))}
onpointerdown={stopPropagation(preventDefault(onMovePointerDown))}
title="Move"
>
<Move size={12} />
@@ -556,7 +596,7 @@
{/if}
</div>
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob'}
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"

View File

@@ -33,6 +33,7 @@
import { JobService } from '$lib/gen'
import { dfsByModule } from '../previousResults'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { GraphModuleState } from '$lib/components/graph'
import FlowStickyNode from './FlowStickyNode.svelte'
@@ -112,8 +113,10 @@
flowHasChanged
}: Props = $props()
const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } =
const { customUi, selectionManager, history, flowStateStore, flowStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const moveManager = new MoveManager()
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
@@ -121,6 +124,19 @@
// Get NoteEditor context for note position updates
const noteEditorContext = getNoteEditorContext()
$effect(() => {
if (!moveManager.movingModuleId) return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
moveManager.clearMoving()
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
})
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
@@ -444,7 +460,7 @@
insertable
scroll
{minHeight}
moving={$moving?.id}
{moveManager}
maxHeight={minHeight}
modules={flowStore.val.value.modules}
{noteMode}
@@ -501,8 +517,7 @@
}
dfs(flowStore.val.value.modules, (mod, modules, branches) => {
// console.log('mod', mod.id, $moving?.id, detail, branches)
if (mod.id == $moving?.id) {
if (mod.id == moveManager.movingModuleId) {
originalModules = modules
}
if (detail.branch) {
@@ -517,15 +532,20 @@
})
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
await tick()
if ($moving) {
if (moveManager.movingModuleId) {
// console.log('modules', modules, movingModules, movingModule)
push(history, flowStore.val)
let indexToRemove = originalModules.findIndex((m) => $moving?.id == m.id)
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
let [removedModule] = originalModules.splice(indexToRemove, 1)
targetModules.splice(detail.index, 0, removedModule)
// When moving within the same array, removal shifts subsequent indices down by 1
let insertIndex = detail.index
if (originalModules === targetModules && indexToRemove < detail.index) {
insertIndex -= 1
}
targetModules.splice(insertIndex, 0, removedModule)
selectionManager.selectId(removedModule.id)
$moving = undefined
moveManager.clearMoving()
} else {
if (detail.isPreprocessor) {
await insertNewPreprocessorModule(
@@ -656,11 +676,7 @@
}
}}
onMove={(id) => {
if (!$moving || $moving.id !== id) {
$moving = { id }
} else {
$moving = undefined
}
moveManager.toggleMoving(id)
}}
onUpdateMock={(detail) => {
let module = findModuleById(detail.id)

View File

@@ -25,7 +25,6 @@
moduleAction: ModuleActionInfo | undefined
annotation?: string | undefined
nodeState?: FlowNodeState
moving?: string | undefined
duration_ms?: number | undefined
retries?: number | undefined
flowJobs:
@@ -58,7 +57,6 @@
moduleAction = undefined,
annotation = undefined,
nodeState,
moving = undefined,
duration_ms = undefined,
retries = undefined,
flowJobs,
@@ -72,7 +70,7 @@
maximizeSubflow
}: Props = $props()
const { selectionManager } = getGraphContext()
const { selectionManager, moveManager } = getGraphContext()
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const { flowStore } = flowEditorContext || {}
@@ -106,15 +104,30 @@
onSelect(mod.id)
}
}
/** Whether this module should be faded because it or its parent subflow is being moved/dragged */
let isPartOfMovingSubflow = $derived(moveManager?.draggedNodeIds?.has(mod.id) ?? false)
let fadedClass = $derived(
isPartOfMovingSubflow
? moveManager?.movingModuleId
? 'opacity-50'
: moveManager?.dragging
? 'opacity-30'
: ''
: ''
)
</script>
{#if mod}
<div class="relative">
{#if moving == mod.id}
<div class="absolute z-10 right-20 top-0.5 center-center">
<Button variant="accent" on:click={() => dispatch('move')} size="xs" destructive
>Cancel move</Button
>
{#if moveManager?.movingModuleId == mod.id}
<div class="absolute z-10 inset-0 flex items-center justify-center">
<Button variant="accent" on:click={() => dispatch('move')} size="xs" destructive>
Cancel move
<kbd class="ml-1 text-2xs opacity-60 font-mono">Esc</kbd>
</Button>
</div>
{/if}
@@ -152,7 +165,7 @@
</div>
{/if}
<div class={moving == mod.id ? 'opacity-50' : ''}>
<div class={fadedClass}>
{#if mod.value.type === 'forloopflow' || mod.value.type === 'whileloopflow'}
<FlowModuleSchemaItem
deletable={insertable}

View File

@@ -74,7 +74,6 @@ export type CurrentEditor =
export type FlowEditorContext = {
selectionManager: SelectionManager
currentEditor: Writable<CurrentEditor>
moving: Writable<{ id: string } | undefined>
previewArgs: StateStore<Record<string, any>>
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
flowEditorDrawer: Writable<FlowEditorDrawer | undefined>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { useSvelteFlow, type Edge, type Node } from '@xyflow/svelte'
import { onMount } from 'svelte'
import { getSubflowNodeIds, type MoveManager } from './moveManager.svelte'
import type { GraphEventHandlers } from './graphBuilder.svelte'
import DragGhost from './DragGhost.svelte'
let {
moveManager,
eventHandlers,
edges,
nodes
}: {
moveManager: MoveManager
eventHandlers: GraphEventHandlers
edges: Edge[]
nodes: Node[]
} = $props()
const { screenToFlowPosition } = useSvelteFlow()
onMount(() => {
moveManager.setScreenToFlowPosition(screenToFlowPosition)
moveManager.setComputeDraggedNodeIds((moduleId) => getSubflowNodeIds(moduleId, nodes, edges))
})
$effect(() => {
if (!moveManager.dragging) return
function onPointerMove(e: PointerEvent) {
moveManager.updateDrag(e.clientX, e.clientY)
}
function onPointerUp(_e: PointerEvent) {
const moduleId = moveManager.dragging?.moduleId
const zone = moveManager.endDrag()
if (zone && moduleId) {
// Set movingModuleId directly (non-toggle) so the insert handler knows which module to relocate
moveManager.setMoving(moduleId)
// Then trigger the insert, which detects movingModuleId and performs the splice
eventHandlers.insert({
sourceId: zone.sourceId,
targetId: zone.targetId,
branch: zone.branch,
index: zone.index
})
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
moveManager.cancelDrag()
}
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
document.addEventListener('keydown', onKeyDown, true)
return () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
document.removeEventListener('keydown', onKeyDown, true)
}
})
</script>
<DragGhost {moveManager} {nodes} {edges} />

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { untrack } from 'svelte'
import { useSvelteFlow, type Node, type Edge } from '@xyflow/svelte'
import { getSubflowNodeIds, type MoveManager } from './moveManager.svelte'
import { NODE } from './util'
import MiniFlowGraph from './MiniFlowGraph.svelte'
import { Move } from 'lucide-svelte'
let { moveManager, nodes, edges }: { moveManager: MoveManager; nodes: Node[]; edges: Edge[] } =
$props()
const { getViewport } = useSvelteFlow()
const PADDING = 10
/** Approximate position of the drag handle (move button) on the node,
* used to offset the ghost so the dragged node aligns with the cursor.
* The handle is at the top-right of the 275px-wide node (right-4 = 16px inset). */
const DRAG_HANDLE_OFFSET = { x: -90, y: 10 }
/** Offset so the cursor indicator icon doesn't overlap the cursor tip */
const CURSOR_INDICATOR_OFFSET = 8
function nodeOffset(n: Node): number {
return ((n.data as Record<string, unknown>)?.offset as number) ?? 0
}
function getSubflowNodesAndEdges(
moduleId: string,
allNodes: Node[],
allEdges: Edge[]
): { sfNodes: Node[]; sfEdges: Edge[] } {
const nodeIds = getSubflowNodeIds(moduleId, allNodes, allEdges)
const sfNodes = allNodes.filter((n) => nodeIds.has(n.id))
const sfEdges = allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
return { sfNodes, sfEdges }
}
/** Resolve a node's position to absolute flow coordinates.
* xyflow child nodes (with parentId) have positions relative to their parent. */
function absolutePosition(n: Node, allNodes: Node[], depth = 0): { x: number; y: number } {
if (n.parentId && depth < 20) {
const parent = allNodes.find((p) => p.id === n.parentId)
if (parent) {
const parentAbs = absolutePosition(parent, allNodes, depth + 1)
return { x: parentAbs.x + n.position.x, y: parentAbs.y + n.position.y }
}
}
return { x: n.position.x, y: n.position.y }
}
function computeGhost(moduleId: string, allNodes: Node[], allEdges: Edge[]) {
const { sfNodes, sfEdges } = getSubflowNodesAndEdges(moduleId, allNodes, allEdges)
if (sfNodes.length === 0) return undefined
// Compute bounding box using absolute positions
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
for (const n of sfNodes) {
const abs = absolutePosition(n, allNodes)
const x = abs.x + nodeOffset(n)
const y = abs.y
const w = n.measured?.width ?? NODE.width
const h = n.measured?.height ?? NODE.height
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + w)
maxY = Math.max(maxY, y + h)
}
const bbWidth = maxX - minX + PADDING * 2
const bbHeight = maxY - minY + PADDING * 2
const scale = getViewport().zoom
const containerWidth = Math.round(bbWidth * scale)
const containerHeight = Math.round(bbHeight * scale)
// Compute the offset so the dragged node's center aligns with the cursor
const mainNode = sfNodes.find((n) => n.id === moduleId)
let offsetX = containerWidth / 2
let offsetY = containerHeight / 2
if (mainNode) {
const mainAbs = absolutePosition(mainNode, allNodes)
const mx = mainAbs.x + nodeOffset(mainNode) - minX + PADDING
const my = mainAbs.y - minY + PADDING
const mw = mainNode.measured?.width ?? NODE.width
const mh = mainNode.measured?.height ?? NODE.height
offsetX = (mx + mw / 2) * scale
offsetY = (my + mh / 2) * scale
}
// Clone nodes/edges with interactive features disabled
const ghostNodes = sfNodes.map((n) => ({
...n,
data: { ...n.data, insertable: false, editMode: false }
}))
const ghostEdges = sfEdges.map((e) => ({
...e,
data: { ...e.data, insertable: false, editMode: false }
}))
// Viewport so the ghost renders at the same zoom as the main flow
const initialViewport = {
x: (PADDING - minX) * scale,
y: (PADDING - minY) * scale,
zoom: scale
}
return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport }
}
let isNearDrop = $derived(moveManager.nearestDropZone != null)
let ghost = $derived.by(() => {
const moduleId = moveManager.dragging?.moduleId
if (!moduleId) return undefined
// Compute ghost once at drag start — don't react to node/edge changes during drag
return untrack(() => computeGhost(moduleId, nodes, edges))
})
</script>
{#if moveManager.dragging}
<div
class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop
? 'bg-surface-accent-primary text-white'
: 'bg-surface text-secondary'}"
style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
>
<Move size={12} />
</div>
{#if ghost}
<div
class="fixed pointer-events-none z-[10000]"
style="left: {moveManager.ghostScreenX +
DRAG_HANDLE_OFFSET.x}px; top: {moveManager.ghostScreenY +
DRAG_HANDLE_OFFSET.y}px; transform: translate({-ghost.offsetX}px, {-ghost.offsetY}px);"
>
<div
style="opacity: {isNearDrop
? 0.8
: 0.25}; width: {ghost.containerWidth}px; height: {ghost.containerHeight}px; transition: opacity 150ms ease;"
>
<MiniFlowGraph
nodes={ghost.ghostNodes}
edges={ghost.ghostEdges}
width={ghost.containerWidth}
height={ghost.containerHeight}
initialViewport={ghost.initialViewport}
/>
</div>
</div>
{/if}
{/if}

View File

@@ -68,6 +68,8 @@
import { SelectionManager } from './selectionUtils.svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { NoteManager } from './noteManager.svelte'
import type { MoveManager } from './moveManager.svelte'
import DragCoordinator from './DragCoordinator.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
@@ -109,7 +111,7 @@
earlyStop?: boolean
cache?: boolean
scroll?: boolean
moving?: string | undefined
moveManager?: MoveManager
// Download: display a top level button to open the graph in a new tab
download?: boolean
fullSize?: boolean
@@ -196,7 +198,7 @@
earlyStop = false,
cache = false,
scroll = false,
moving = undefined,
moveManager = undefined,
download = false,
fullSize = false,
disableAi = false,
@@ -279,6 +281,7 @@
useDataflow,
showAssets,
noteManager,
moveManager,
clearFlowSelection,
yOffset,
diffManager
@@ -752,7 +755,6 @@
success,
$useDataflow,
untrack(() => selectedId),
moving,
simplifiableFlow,
triggerNode ? path : undefined,
expandedSubflows
@@ -912,6 +914,9 @@
{:else}
<SvelteFlowProvider>
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
{#if moveManager}
<DragCoordinator {moveManager} eventHandlers={eventHandler} {edges} nodes={nodesWithOffset} />
{/if}
{#if sharedViewport && onViewportChange}
<ViewportSynchronizer
{sharedViewport}

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { writable } from 'svelte/store'
import { SvelteFlow, SvelteFlowProvider, type Node, type Edge, type Viewport } from '@xyflow/svelte'
import { setGraphContext } from './graphContext'
import { SelectionManager } from './selectionUtils.svelte'
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
import ModuleNode from './renderers/nodes/ModuleNode.svelte'
import InputNode from './renderers/nodes/InputNode.svelte'
import BranchAllStart from './renderers/nodes/BranchAllStart.svelte'
import BranchAllEndNode from './renderers/nodes/BranchAllEndNode.svelte'
import ForLoopEndNode from './renderers/nodes/ForLoopEndNode.svelte'
import ForLoopStartNode from './renderers/nodes/ForLoopStartNode.svelte'
import ResultNode from './renderers/nodes/ResultNode.svelte'
import BranchOneStart from './renderers/nodes/BranchOneStart.svelte'
import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte'
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
import NoBranchNode from './renderers/nodes/NoBranchNode.svelte'
import AssetNode from './renderers/nodes/AssetNode.svelte'
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
import AiToolNode from './renderers/nodes/AIToolNode.svelte'
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import BaseEdge from './renderers/edges/BaseEdge.svelte'
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
let {
nodes,
edges,
width,
height,
initialViewport
}: { nodes: Node[]; edges: Edge[]; width: number; height: number; initialViewport?: Viewport } =
$props()
setGraphContext({
selectionManager: new SelectionManager(),
useDataflow: writable(false),
showAssets: writable(false),
diffManager: createFlowDiffManager()
})
const nodeTypes = {
input2: InputNode,
module: ModuleNode,
branchAllStart: BranchAllStart,
branchAllEnd: BranchAllEndNode,
forLoopEnd: ForLoopEndNode,
forLoopStart: ForLoopStartNode,
result: ResultNode,
whileLoopStart: ForLoopStartNode,
whileLoopEnd: ForLoopEndNode,
branchOneStart: BranchOneStart,
branchOneEnd: BranchOneEndNode,
subflowBound: SubflowBound,
noBranch: NoBranchNode,
asset: AssetNode,
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode
} as any
const edgeTypes = {
edge: BaseEdge,
empty: EmptyEdge,
dataflowedge: DataflowEdge,
hiddenedge: HiddenBaseEdge
} as any
const proOptions = { hideAttribution: true }
const fitViewOptions = { padding: 0 }
</script>
<div style="width: {width}px; height: {height}px;">
<SvelteFlowProvider>
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView={!initialViewport}
{fitViewOptions}
{initialViewport}
nodesDraggable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
{proOptions}
style="width: {width}px; height: {height}px; background: transparent;"
/>
</SvelteFlowProvider>
</div>

View File

@@ -119,7 +119,6 @@ export type InputN = {
eventHandlers: GraphEventHandlers
hasPreprocessor: boolean
insertable: boolean
moving: string | undefined
disableAi: boolean
cache: boolean
earlyStop: boolean
@@ -143,7 +142,6 @@ export type ModuleN = {
id: string
parentIds: string[]
eventHandlers: GraphEventHandlers
moving: string | undefined
flowModuleState: GraphModuleState | undefined
testModuleState: ModuleTestState | undefined
insertable: boolean
@@ -395,7 +393,6 @@ export function graphBuilder(
success: boolean | undefined,
useDataflow: boolean | undefined,
selectedId: string | undefined,
moving: string | undefined,
simplifiableFlow: SimplifiableFlow | undefined,
flowPathForTriggerNode: string | undefined,
expandedSubflows: Record<string, FlowModule[]>
@@ -437,7 +434,6 @@ export function graphBuilder(
id: module.id,
parentIds: [],
eventHandlers: eventHandlers,
moving: moving,
flowModuleState: extra.flowModuleStates?.[module.id],
testModuleState: extra.testModuleStates?.states?.[module.id],
insertable: extra.insertable,
@@ -531,7 +527,6 @@ export function graphBuilder(
sourceId,
targetId,
branch,
moving,
eventHandlers,
simplifiedTriggerView: simplifiableFlow?.simplifiedFlow,
disableMoveIds: options?.disableMoveIds,
@@ -554,7 +549,6 @@ export function graphBuilder(
eventHandlers: eventHandlers,
hasPreprocessor: !!preprocessorModule || flowPathForTriggerNode == undefined,
insertable: extra.insertable,
moving: moving,
disableAi: extra.disableAi,
cache: extra.cache,
earlyStop: extra.earlyStop,

View File

@@ -1,6 +1,7 @@
import { getContext, setContext } from 'svelte'
import type { SelectionManager } from './selectionUtils.svelte'
import type { NoteManager } from './noteManager.svelte'
import type { MoveManager } from './moveManager.svelte'
import type { Writable } from 'svelte/store'
import type { FlowDiffManager } from '../flows/flowDiffManager.svelte'
@@ -9,6 +10,7 @@ export type GraphContext = {
useDataflow: Writable<boolean | undefined>
showAssets: Writable<boolean | undefined>
noteManager?: NoteManager
moveManager?: MoveManager
clearFlowSelection?: () => void
yOffset?: number
diffManager: FlowDiffManager

View File

@@ -0,0 +1,215 @@
import { NODE } from './util'
export type DropZone = {
edgeId: string
sourceId: string
targetId: string
branch: { rootId: string; branch: number } | undefined
index: number
flowPosition: { x: number; y: number }
disableMoveIds: string[]
}
export type DropZoneRegistration = {
sourceId: string
targetId: string
branch: { rootId: string; branch: number } | undefined
index: number
disableMoveIds: string[]
centerX: number
centerY: number
}
type DragInfo = {
moduleId: string
}
/**
* Compute the set of node IDs belonging to a subflow (or a single step).
* Includes the module itself, all nested children (id starts with `moduleId-`),
* and structural edge endpoints whose `disableMoveIds` reference this module.
*/
export function getSubflowNodeIds(
moduleId: string,
allNodes: { id: string }[],
allEdges: { source: string; target: string; data?: any }[]
): Set<string> {
const nodeIdPrefix = moduleId + '-'
const nodeIds = new Set<string>()
for (const n of allNodes) {
if (n.id === moduleId || n.id.startsWith(nodeIdPrefix)) {
nodeIds.add(n.id)
}
}
for (const e of allEdges) {
const disableIds: string[] = e.data?.disableMoveIds ?? []
if (disableIds.includes(moduleId)) {
nodeIds.add(e.source)
nodeIds.add(e.target)
}
}
// Include child nodes (e.g. asset/AI tool nodes) of nodes added via edges.
// Nodes found through disableMoveIds (like inner module "b") may have children
// ("b-asset-in-...") that weren't caught by the initial prefix match on moduleId.
// Only scan children of edge-added nodes that aren't already covered by the
// moduleId prefix (those children were already captured in the first pass).
const newFromEdges: string[] = []
for (const id of nodeIds) {
if (id !== moduleId && !id.startsWith(nodeIdPrefix)) {
newFromEdges.push(id)
}
}
if (newFromEdges.length > 0) {
for (const n of allNodes) {
if (!nodeIds.has(n.id)) {
for (const id of newFromEdges) {
if (n.id.startsWith(id + '-')) {
nodeIds.add(n.id)
break
}
}
}
}
}
return nodeIds
}
export class MoveManager {
dragging = $state<DragInfo | undefined>(undefined)
ghostScreenX = $state(0)
ghostScreenY = $state(0)
nearestDropZone = $state<DropZone | undefined>(undefined)
draggedNodeIds = $state<Set<string>>(new Set())
/** The module ID currently being moved via legacy click-to-move */
movingModuleId = $state<string | undefined>(undefined)
toggleMoving(id: string) {
if (this.movingModuleId === id) {
this.movingModuleId = undefined
this.#updateDraggedNodeIds(undefined)
} else {
this.movingModuleId = id
this.#updateDraggedNodeIds(id)
}
}
setMoving(id: string) {
this.movingModuleId = id
this.#updateDraggedNodeIds(id)
}
clearMoving() {
this.movingModuleId = undefined
this.#updateDraggedNodeIds(undefined)
}
#computeDraggedNodeIds: ((moduleId: string) => Set<string>) | undefined
setComputeDraggedNodeIds(fn: (moduleId: string) => Set<string>) {
this.#computeDraggedNodeIds = fn
}
#updateDraggedNodeIds(moduleId: string | undefined) {
this.draggedNodeIds =
moduleId && this.#computeDraggedNodeIds ? this.#computeDraggedNodeIds(moduleId) : new Set()
}
#screenToFlowPosition: ((pos: { x: number; y: number }) => { x: number; y: number }) | undefined
#registeredDropZones = new Map<string, DropZoneRegistration>()
setScreenToFlowPosition(fn: (pos: { x: number; y: number }) => { x: number; y: number }) {
this.#screenToFlowPosition = fn
}
registerDropZone(edgeId: string, zone: DropZoneRegistration) {
this.#registeredDropZones.set(edgeId, zone)
}
unregisterDropZone(edgeId: string) {
this.#registeredDropZones.delete(edgeId)
}
startDrag(moduleId: string, screenX: number, screenY: number) {
// Clear any active click-to-move so only drag mode is active
this.movingModuleId = undefined
this.dragging = { moduleId }
this.ghostScreenX = screenX
this.ghostScreenY = screenY
this.nearestDropZone = undefined
this.#updateDraggedNodeIds(moduleId)
}
updateDrag(screenX: number, screenY: number) {
if (!this.dragging) return
this.ghostScreenX = screenX
this.ghostScreenY = screenY
if (!this.#screenToFlowPosition) return
const flowPos = this.#screenToFlowPosition({ x: screenX, y: screenY })
this.nearestDropZone = this.#findNearestDropZone(flowPos)
}
endDrag(): DropZone | undefined {
if (!this.dragging) return undefined
const zone = this.nearestDropZone
this.dragging = undefined
this.nearestDropZone = undefined
this.#updateDraggedNodeIds(undefined)
return zone
}
cancelDrag() {
this.dragging = undefined
this.nearestDropZone = undefined
this.#updateDraggedNodeIds(undefined)
}
#findNearestDropZone(flowPos: { x: number; y: number }): DropZone | undefined {
if (!this.dragging) return undefined
const draggedId = this.dragging.moduleId
let best: DropZone | undefined = undefined
let bestDist = Infinity
// Box half-dimensions matching the visual drop zone
const halfW = NODE.width / 2
const halfH = NODE.gap.vertical / 2
for (const [edgeId, zone] of this.#registeredDropZones) {
if (zone.disableMoveIds.includes(draggedId)) continue
// Skip edges adjacent to the dragged node (no-op move)
if (zone.sourceId === draggedId || zone.targetId === draggedId) continue
const dx = Math.abs(flowPos.x - zone.centerX)
const dy = Math.abs(flowPos.y - zone.centerY)
// Axis-aligned bounding box test
if (dx <= halfW && dy <= halfH) {
// Manhattan-like distance for ranking overlapping boxes
const dist = dx + dy
if (dist < bestDist) {
bestDist = dist
best = {
edgeId,
sourceId: zone.sourceId,
targetId: zone.targetId,
branch: zone.branch,
index: zone.index,
flowPosition: { x: zone.centerX, y: zone.centerY },
disableMoveIds: zone.disableMoveIds
}
}
}
}
return best
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabel } from '@xyflow/svelte'
import { ClipboardCopy, Hourglass } from 'lucide-svelte'
import { CircleDot, Hourglass } from 'lucide-svelte'
import type { GraphEventHandlers } from '../../graphBuilder.svelte'
import { getStraightLinePath } from '../utils'
import { twMerge } from 'tailwind-merge'
@@ -13,10 +13,10 @@
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import { getGraphContext } from '../../graphContext'
const { useDataflow, showAssets } = getGraphContext()
const { useDataflow, showAssets, moveManager } = getGraphContext()
let {
// id,
id,
sourceX,
sourceY,
sourcePosition,
@@ -32,7 +32,6 @@
sourceId: string
branch: { rootId: string; branch: number } | undefined
targetId: string
moving: string | undefined
eventHandlers: GraphEventHandlers
index: number
enableTrigger: boolean
@@ -74,16 +73,50 @@
let suspendStatus: Record<string, { job: Job; nb: number }> | undefined = $derived(
data?.suspendStatus
)
let centerY = $derived(
sourceY +
32 +
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
)
let isDragging = $derived(!!moveManager?.dragging)
let draggedId = $derived(moveManager?.dragging?.moduleId)
let isValidDropTarget = $derived(
isDragging &&
data?.insertable &&
draggedId !== undefined &&
!data.disableMoveIds?.includes(draggedId) &&
data.sourceId !== draggedId &&
data.targetId !== draggedId
)
let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id)
let isAdjacentToDragged = $derived(
isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId)
)
// Register this edge's drop zone position with the drag manager so proximity
// detection uses the actual xyflow-computed position rather than re-deriving it.
$effect(() => {
if (!data?.insertable || !moveManager) return
const centerX = sourceX
moveManager.registerDropZone(id, {
sourceId: data.sourceId,
targetId: data.targetId,
branch: data.branch,
index: data.index,
disableMoveIds: data.disableMoveIds ?? [],
centerX,
centerY
})
return () => moveManager.unregisterDropZone(id)
})
</script>
<EdgeLabel
x={sourceX}
y={sourceY +
32 +
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)}
class="base-edge"
style=""
>
<EdgeLabel x={sourceX} y={centerY} class="base-edge" style="">
{#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'}
<div
class="px-2 py-0.5 rounded-md bg-surface shadow-md text-violet-700 dark:text-violet-400 text-xs flex items-center gap-1"
@@ -118,7 +151,17 @@
</div>
{/if}
</div>
{:else if data?.insertable && !$useDataflow && !data?.moving}
{:else if isDragging && isValidDropTarget}
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
<div class="relative flex items-center justify-center" style="width: 275px; height: 20px;">
{#if isNearestDrop}
<div class="absolute inset-0 rounded-md bg-accent/5 transition-opacity duration-150"
></div>
{/if}
{@render dropTargetIndicator(isNearestDrop)}
</div>
</div>
{:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
style:transform="translate(-50%, -50%)"
@@ -168,9 +211,9 @@
</div>
{/if}
{#if data?.moving}
{#if moveManager?.movingModuleId && data?.insertable}
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
{#if data.moving && !data.disableMoveIds?.includes(data.moving)}
{#if !data.disableMoveIds?.includes(moveManager.movingModuleId)}
<button
title="Paste module"
onclick={() => {
@@ -182,24 +225,34 @@
})
}}
type="button"
class={twMerge(
'w-6 h-6 flex items-center justify-center',
'border border-gray-300 dark:border-gray-500',
'text-primary text-sm',
'bg-surface focus:outline-none hover:bg-surface-hover focus:ring-4 focus:ring-surface-selected rounded-full '
)}
class="group relative flex items-center justify-center"
style="width: 275px; height: 20px;"
>
<ClipboardCopy size={14} />
<div
class="absolute inset-0 rounded-md bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
></div>
{@render dropTargetIndicator(false)}
</button>
{/if}
</div>
{/if}
</EdgeLabel>
{#snippet dropTargetIndicator(highlighted: boolean)}
<div
class={twMerge(
'w-[20px] h-[20px] flex items-center justify-center rounded-md bg-surface-secondary transition-all duration-150 group-hover:text-accent',
highlighted ? 'text-accent' : 'text-primary'
)}
>
<CircleDot size={12} />
</div>
{/snippet}
<BaseEdge
path={completeEdge}
{markerEnd}
class={$useDataflow ? 'hidden' : ''}
class={$useDataflow ? 'hidden' : isAdjacentToDragged ? 'opacity-30' : ''}
interactionWidth={0}
style={undefined}
label={undefined}

View File

@@ -277,9 +277,10 @@
interface Props {
data: AiToolN['data']
id?: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
@@ -292,7 +293,7 @@
)
</script>
<NodeWrapper>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div

View File

@@ -242,11 +242,12 @@
interface Props {
data: AssetN['data']
id?: string
}
const flowGraphAssetsCtx = getContext<any | undefined>('FlowGraphAssetContext')
let { data }: Props = $props()
let { data, id }: Props = $props()
const isSelected = $derived(assetEq(flowGraphAssetsCtx?.val.selectedAsset, data.asset))
const cachedResourceMetadata = $derived.by(() => {
@@ -267,7 +268,7 @@
)
</script>
<NodeWrapper wrapperClass="bg-surface-secondary rounded-md">
<NodeWrapper wrapperClass="bg-surface-secondary rounded-md" nodeId={id}>
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Tooltip customBgClass="bg-surface-tertiary">

View File

@@ -13,8 +13,9 @@
interface Props {
data: AssetsOverflowedN['data']
id?: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
let isOpen = $state(false)
@@ -37,7 +38,7 @@
const colors = $derived(getNodeColorClasses(undefined, includesSelected))
</script>
<NodeWrapper>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Popover

View File

@@ -13,7 +13,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={'Collect result from all branches'}

View File

@@ -16,7 +16,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}

View File

@@ -15,7 +15,7 @@
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}

View File

@@ -13,7 +13,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} nodeId={id}>
{#snippet children({ darkMode })}
{#if data.simplifiedTriggerView}
<VirtualItem

View File

@@ -56,7 +56,7 @@
let filteredInput = $derived(filterIterFromInput($pickablePropertiesFiltered?.flow_input))
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}

View File

@@ -66,7 +66,6 @@
(state?.iteration_total ?? '?')
: ''}
nodeState={state?.skipped ? '_Skipped' : type}
moving={data.moving}
duration_ms={state?.duration_ms}
retries={state?.retries}
{flowJobs}

View File

@@ -4,6 +4,7 @@
import { Plus } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Button } from '$lib/components/common'
import { getGraphContext } from '../../graphContext'
let funcDesc = $state('')
interface Props {
@@ -11,9 +12,13 @@
}
let { data }: Props = $props()
const { moveManager } = getGraphContext()
let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId)
let open = $state(false)
</script>
{#if !isMoving}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Popover
bind:isOpen={open}
@@ -110,3 +115,4 @@
/>
{/snippet}
</Popover>
{/if}

View File

@@ -4,12 +4,13 @@
import type { NoBranchN } from '../../graphBuilder.svelte'
interface Props {
data: NoBranchN['data']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label ?? 'No branches'}

View File

@@ -3,6 +3,7 @@
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
enableSourceHandle?: boolean
@@ -10,6 +11,8 @@
offset?: number
wrapperClass?: string
contextMenuItems?: ContextMenuItem[]
/** xyflow node ID — used to fade nodes that are part of a moving subflow */
nodeId?: string
children?: import('svelte').Snippet<[any]>
}
@@ -19,9 +22,16 @@
offset = 0,
wrapperClass = '',
contextMenuItems = undefined,
nodeId = undefined,
children
}: Props = $props()
const { moveManager } = getGraphContext()
let faded = $derived(
nodeId != null && (moveManager?.draggedNodeIds?.has(nodeId) ?? false)
)
let darkMode: boolean = $state(false)
</script>
@@ -29,14 +39,14 @@
{#if contextMenuItems && contextMenuItems.length > 0}
<ContextMenu items={contextMenuItems}>
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
{@render children?.({ darkMode })}
</div>
{@render handles()}
</ContextMenu>
{:else}
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
{@render children?.({ darkMode })}
</div>

View File

@@ -13,7 +13,7 @@
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={'Collect result from chosen branch'}

View File

@@ -74,7 +74,6 @@
const previewArgsStore = $state({ val: {} })
const scriptEditorDrawer = writable(undefined)
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const stepsInputArgs = new StepsInputArgs()
@@ -93,7 +92,6 @@
previewArgs: previewArgsStore,
scriptEditorDrawer,
flowEditorDrawer: writable(undefined),
moving,
history,
pathStore: writable(''),
flowStateStore,

View File

@@ -111,7 +111,7 @@ SETTINGS
# Extracts --features <value> from args, exports CARGO_FEATURES, returns remaining args.
# Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}"
parse_features_flag() {
parse_flags() {
remaining_args=()
while (( $# )); do
case $1 in
@@ -127,6 +127,10 @@ parse_features_flag() {
export CARGO_FEATURES=${1#--features=}
shift
;;
--clone-db)
export WM_CLONE_DB=1
shift
;;
*)
remaining_args+=("$1")
shift
@@ -142,7 +146,7 @@ cmd_add() {
ensure_tmux
check_dev_db
parse_features_flag "$@"
parse_flags "$@"
set -- "${remaining_args[@]}"
# Snapshot worktree list before
@@ -176,7 +180,7 @@ cmd_open() {
ensure_tmux
check_dev_db
parse_features_flag "$@"
parse_flags "$@"
set -- "${remaining_args[@]}"
# Write CARGO_FEATURES to .env.local if specified
@@ -391,6 +395,7 @@ case ${1-} in
print -u2 ""
print -u2 "Options:"
print -u2 " --features <features> Cargo features for the backend (e.g. \"enterprise,parquet\")"
print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one"
exit 1
;;
esac

View File

@@ -61,9 +61,17 @@ if command -v psql &>/dev/null; then
if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then
echo "Database $db_name already exists"
else
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name}" 2>/dev/null \
&& echo "Created database $db_name" \
|| echo "WARNING: Could not create database $db_name (is PostgreSQL running?)" >&2
if [[ -n "${WM_CLONE_DB:-}" ]]; then
# Terminate active connections so CREATE DATABASE ... TEMPLATE works
psql "$db_conn/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'windmill' AND pid <> pg_backend_pid();" 2>/dev/null || true
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name} TEMPLATE windmill" 2>/dev/null \
&& echo "Created database $db_name (template: windmill)" \
|| echo "WARNING: Could not create database $db_name from template windmill" >&2
else
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name}" 2>/dev/null \
&& echo "Created database $db_name" \
|| echo "WARNING: Could not create database $db_name (is PostgreSQL running?)" >&2
fi
fi
db_url="${db_conn}/${db_name}?sslmode=disable"
# Run migrations against the new database