Header removals wait for the code editor to be left: autosave saves with drop=false, so follow_header adds and renames from the header but keeps a port or setting whose parameter is gone, and leaving the editor (blur or closing the panel) saves once more with removals; source saves of a flow run one at a time

This commit is contained in:
2026-10-15 21:31:04 +02:00
parent 5ca90ffece
commit ce2ce4e3e7
10 changed files with 105 additions and 24 deletions
+7 -1
View File
@@ -1119,11 +1119,14 @@ export class FlowsService {
*
* A node with its own code takes its input ports and settings from the
* header of ``process`` (see `follow_header`), and the answer carries the
* node when that changed it.
* node when that changed it. ``drop=false`` is the editor's autosave, which
* adds and renames from the header but keeps what it no longer names until
* the editor is left.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
* @param data.requestBody
* @param data.drop
* @returns NodeSourceSaved Successful Response
* @throws ApiError
*/
@@ -1135,6 +1138,9 @@ export class FlowsService {
name: data.name,
node_id: data.nodeId
},
query: {
drop: data.drop
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
+1
View File
@@ -1849,6 +1849,7 @@ export type FlowsReadNodeSourceData = {
export type FlowsReadNodeSourceResponse = (NodeSource);
export type FlowsSaveNodeSourceData = {
drop?: boolean;
name: string;
nodeId: string;
requestBody: NodeSource;
+16 -3
View File
@@ -865,7 +865,15 @@ function FlowEditorInner({
})
const sourceMutation = useMutation({
mutationFn: async ({ nodeId, code }: { nodeId: string; code: string }) => {
mutationFn: async ({
nodeId,
code,
drop,
}: {
nodeId: string
code: string
drop?: boolean
}) => {
// The server reads the ports off the header and compares them with its
// draft, so the ports on the canvas have to be there first.
await flush()
@@ -873,8 +881,12 @@ function FlowEditorInner({
name: flowName,
nodeId,
requestBody: { code },
drop,
})
},
// One at a time: the save that drops a port must not land before the
// autosave that kept it.
scope: { id: `source:${flowName}` },
onSuccess: (saved) => {
const followed = saved.node
if (followed && saved.version) {
@@ -1454,8 +1466,9 @@ function FlowEditorInner({
onToggleExpand={() => setEditorExpanded((wide) => !wide)}
onChange={updateNode}
onRenameMessage={proposeRename}
onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
onSaveSource={(code, drop) => {
if (selected)
sourceMutation.mutate({ nodeId: selected.id, code, drop })
}}
// Sharing rewrites the stored document, so the canvas takes the
// server's copy rather than keeping its own.
+10 -2
View File
@@ -1,5 +1,5 @@
import Editor from "@monaco-editor/react"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useTheme } from "@/components/theme-provider"
import { Skeleton } from "@/components/ui/skeleton"
@@ -13,12 +13,15 @@ import { monacoFontFamily, setupMonaco } from "./monacoSetup"
export default function NodeEditor({
value,
onChange,
onBlur,
language = "python",
readOnly = false,
autoFocus = false,
}: {
value: string
onChange: (next: string) => void
/** Focus left the editor, its find and suggest widgets included. */
onBlur?: () => void
language?: "python" | "json"
/** Generated code: readable, and not this panel's to change. */
readOnly?: boolean
@@ -27,6 +30,8 @@ export default function NodeEditor({
}) {
const { resolvedTheme } = useTheme()
const [ready, setReady] = useState(false)
const blur = useRef(onBlur)
blur.current = onBlur
useEffect(() => {
setupMonaco()
@@ -43,7 +48,10 @@ export default function NodeEditor({
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
value={value}
onChange={(next) => onChange(next ?? "")}
onMount={autoFocus ? (editor) => editor.focus() : undefined}
onMount={(editor) => {
editor.onDidBlurEditorWidget(() => blur.current?.())
if (autoFocus) editor.focus()
}}
loading={<Skeleton className="h-full w-full rounded-md" />}
options={{
readOnly,
+27 -9
View File
@@ -1409,7 +1409,8 @@ function PanelBody({
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
/** `drop` is false while the code is still being typed; see `settle`. */
onSaveSource: (code: string, drop: boolean) => void
onShared: () => void
onToggleExpand: () => void
}) {
@@ -1431,6 +1432,7 @@ function PanelBody({
// only overwrite the first.
const portMove = useRef<[string, string | null] | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Code typed since the editor was last left.
const pending = useRef<string | null>(null)
const save = useRef(onSaveSource)
save.current = onSaveSource
@@ -1441,7 +1443,22 @@ function PanelBody({
clearTimeout(timer.current)
timer.current = null
}
if (pending.current !== null) save.current(pending.current)
if (pending.current !== null) save.current(pending.current, false)
}
/**
* The editor was left, so the code is saved once more, now dropping the
* ports and settings its header no longer names. Saves while typing only
* add and rename, so a parameter deleted mid-edit keeps its port and
* message until then, and typing it back changes nothing.
*/
const settle = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current !== null) save.current(pending.current, true)
pending.current = null
}
const editCode = (next: string) => {
@@ -1450,7 +1467,7 @@ function PanelBody({
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = null
save.current(next)
save.current(next, false)
}, 1000)
}
@@ -1520,13 +1537,13 @@ function PanelBody({
["mod+s"],
)
// Closing the panel must not lose the last keystrokes.
// Closing the panel leaves the editor too, and must not lose the last
// keystrokes.
useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current)
if (pending.current !== null) save.current(pending.current)
}
if (timer.current) clearTimeout(timer.current)
if (pending.current !== null) save.current(pending.current, true)
pending.current = null
}
}, [])
@@ -1704,6 +1721,7 @@ function PanelBody({
<NodeEditor
value={code ?? source?.code ?? ""}
onChange={editCode}
onBlur={settle}
readOnly={Boolean(origin) || !mayEditCode}
/>
</Suspense>
@@ -1745,7 +1763,7 @@ export function NodePanel({
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onSaveSource: (code: string, drop: boolean) => void
onShared: () => void
onToggleExpand: () => void
onClose: () => void