diff --git a/frontend/src/lib/components/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index f1c4daa962..9292e694b9 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -59,6 +59,11 @@ // import { createConfiguredEditor } from 'vscode/monaco' // import type { IStandaloneCodeEditor } from 'vscode/vscode/vs/editor/standalone/browser/standaloneCodeEditor' + /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */ + const CHANGE_TIMEOUT = 200 + + let changeTimeoutId: number | undefined = undefined + let divEl: HTMLDivElement | null = null let editor = $state(null) let model: meditor.ITextModel @@ -98,7 +103,8 @@ readOnly = false, minHeight = 1000, renderLineHighlight = 'none', - suggestion + suggestion, + leadingChangeSync = false }: { lang: string code?: string @@ -130,6 +136,11 @@ minHeight?: number renderLineHighlight?: 'all' | 'line' | 'gutter' | 'none' suggestion?: string + /** Materialize `code` on the first change of a burst instead of only after + * the trailing debounce. Set it when a control's enabled state derives from + * `code`; leave it off where each extra sync costs work downstream (an app + * code input feeding an autoRefresh runnable re-runs a job per sync). */ + leadingChangeSync?: boolean } = $props() let yPadding = MONACO_Y_PADDING @@ -156,11 +167,21 @@ code = ncode } editor?.setValue(ncode) + // setValue emits a change event of its own; drop the burst it opens so an edit + // made right after an authoritative overwrite still counts as a leading change. + cancelPendingChanges() if (formatCode) { format() } } + function cancelPendingChanges(): void { + if (changeTimeoutId !== undefined) { + clearTimeout(changeTimeoutId) + changeTimeoutId = undefined + } + } + export function formatCode(): void { format() } @@ -408,12 +429,21 @@ pasteListenerCleanup = () => pasteTarget?.removeEventListener('keydown', onPasteKeydown, true) } - let timeoutModel: number | undefined = undefined - editor.onDidChangeModelContent((event) => { - timeoutModel && clearTimeout(timeoutModel) - timeoutModel = setTimeout(() => { + editor.onDidChangeModelContent(() => { + // A paste is a single change, so under a trailing-only sync `code` stays + // stale for CHANGE_TIMEOUT after it: a consumer gating a control on `code` + // (FlowYamlEditor disables "Apply changes" until it differs from a snapshot) + // then swallows a click made in that window. Schedule before firing so a + // re-entrant change from a consumer does not count as leading too. + const leading = leadingChangeSync && changeTimeoutId === undefined + cancelPendingChanges() + changeTimeoutId = setTimeout(() => { + changeTimeoutId = undefined updateCode() - }, 200) + }, CHANGE_TIMEOUT) + if (leading) { + updateCode() + } }) editor.onDidChangeCursorPosition((event) => { if (key) editorPositionMap[key] = event.position @@ -621,6 +651,7 @@ onDestroy(() => { try { valueAfterDispose = getCode() + cancelPendingChanges() pasteListenerCleanup?.() vimDisposable?.dispose() model && model.dispose() diff --git a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte index 47ca809edb..9879b2899b 100644 --- a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte +++ b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte @@ -31,6 +31,23 @@ editor?.setCode(code) } + /** `value` is assigned unconditionally below and `FlowEditor` dereferences + * `flowStore.val.value.modules`, so an OpenFlow document missing it takes the + * whole editor down mid-render — after the toast has already claimed success. + * Reject it up front instead. */ + function validateShape(parsed: unknown) { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('the document must be a mapping (key: value)') + } + const value = (parsed as Record).value + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error("missing 'value' - paste a whole OpenFlow document, not just its value") + } + if (!Array.isArray((value as Record).modules)) { + throw new Error("'value.modules' must be a list") + } + } + function validateGroups(groups: { start_id: string; end_id: string }[] | undefined) { if (!groups) return const seen = new Set() @@ -46,6 +63,7 @@ function apply() { try { const parsed = YAML.parse(code) + validateShape(parsed) validateGroups(parsed.value?.groups) if (parsed.summary && typeof parsed.summary === 'string') { flowStore.val.summary = parsed.summary @@ -104,6 +122,7 @@ minHeight={editorHeight} bind:code lang="yaml" + leadingChangeSync /> {/await} diff --git a/frontend/src/lib/components/raw_apps/RawAppYamlEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppYamlEditor.svelte index 59b0885989..4ac143976b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppYamlEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppYamlEditor.svelte @@ -25,14 +25,7 @@ onApply: (update: RawAppYamlUpdate) => void } - let { - drawer = $bindable(), - summary, - files, - runnables, - data, - onApply - }: Props = $props() + let { drawer = $bindable(), summary, files, runnables, data, onApply }: Props = $props() let code = $state('') let initialCode = $state('') @@ -106,6 +99,7 @@ minHeight={editorHeight} bind:code lang="yaml" + leadingChangeSync /> {/await} diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte index 28992cf72c..5c01c84918 100644 --- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte @@ -289,7 +289,7 @@ {/if} {#if selected === 'OpenAPI' || (selected === 'OpenAPI_File' && !emptyStringTrimmed(openApiFile)) || (selected === 'OpenAPI_URL' && !emptyStringTrimmed(openApiUrl))} {#key forceRerender} - + {/key} {/if}