pasting YAML over existing code fails to apply (#10453)

* fix: sync SimpleEditor code on the leading edge of a change burst

* style: condense change-debounce comment to the 4-line rule

* fix: scope leading code sync to the YAML drawers that gate on it

* fix: apply leading code sync to the remaining code-gated editors

* fix: reject OpenFlow YAML without value.modules instead of crashing the editor
This commit is contained in:
Ruben Fiszel
2026-08-02 00:49:59 +02:00
committed by GitHub
parent 053fb98428
commit 57b59a8ec8
5 changed files with 61 additions and 15 deletions
@@ -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<meditor.IStandaloneCodeEditor | null>(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()
@@ -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<string, unknown>).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<string, unknown>).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<string>()
@@ -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
/>
</div>
{/await}
@@ -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
/>
</div>
{/await}
@@ -289,7 +289,7 @@
{/if}
{#if selected === 'OpenAPI' || (selected === 'OpenAPI_File' && !emptyStringTrimmed(openApiFile)) || (selected === 'OpenAPI_URL' && !emptyStringTrimmed(openApiUrl))}
{#key forceRerender}
<SimpleEditor class="h-96" {lang} bind:code />
<SimpleEditor class="h-96" {lang} bind:code leadingChangeSync />
{/key}
{/if}
<Button
@@ -624,6 +624,7 @@
lang="yaml"
class="h-full"
fixedOverflowWidgets={false}
leadingChangeSync
/>
{#snippet actions()}
<Button
@@ -676,6 +677,7 @@
lang="yaml"
class="h-full"
fixedOverflowWidgets={false}
leadingChangeSync
/>
{/if}
{#snippet actions()}