Compare commits
4 Commits
v1.647.2
...
removeplug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90782336c8 | ||
|
|
b121f4388b | ||
|
|
5ebaa43aa1 | ||
|
|
7a5e487878 |
@@ -110,7 +110,6 @@
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
68
frontend/src/lib/components/graph/DragCoordinator.svelte
Normal file
68
frontend/src/lib/components/graph/DragCoordinator.svelte
Normal 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} />
|
||||
151
frontend/src/lib/components/graph/DragGhost.svelte
Normal file
151
frontend/src/lib/components/graph/DragGhost.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
96
frontend/src/lib/components/graph/MiniFlowGraph.svelte
Normal file
96
frontend/src/lib/components/graph/MiniFlowGraph.svelte
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
215
frontend/src/lib/components/graph/moveManager.svelte.ts
Normal file
215
frontend/src/lib/components/graph/moveManager.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
(state?.iteration_total ?? '?')
|
||||
: ''}
|
||||
nodeState={state?.skipped ? '_Skipped' : type}
|
||||
moving={data.moving}
|
||||
duration_ms={state?.duration_ms}
|
||||
retries={state?.retries}
|
||||
{flowJobs}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user