diff --git a/frontend/src/components/form/JsonEditor.tsx b/frontend/src/components/form/JsonEditor.tsx index f7372730e..f820354e4 100644 --- a/frontend/src/components/form/JsonEditor.tsx +++ b/frontend/src/components/form/JsonEditor.tsx @@ -157,8 +157,18 @@ const JsonEditor = forwardRef(function JsonEd if (!view) return; const current = view.state.doc.toString(); if (value === current) return; + let from = 0; + while (from < current.length && from < value.length && current[from] === value[from]) { + from += 1; + } + let oldEnd = current.length; + let newEnd = value.length; + while (oldEnd > from && newEnd > from && current[oldEnd - 1] === value[newEnd - 1]) { + oldEnd -= 1; + newEnd -= 1; + } valueRef.current = value; - view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); + view.dispatch({ changes: { from, to: oldEnd, insert: value.slice(from, newEnd) } }); }, [value]); useEffect(() => { diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx index 7c6866f08..a8d948762 100644 --- a/frontend/src/pages/inbounds/form/advanced-editors.tsx +++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx @@ -81,6 +81,47 @@ export function AdvancedSliceEditor({ ); } +function sameJsonValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => sameJsonValue(item, right[index])) + ); + } + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; + const a = left as Record; + const b = right as Record; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => key in b && sameJsonValue(a[key], b[key])) + ); +} + +function updateTagOnly(raw: string, canonical: string): string | null { + let expected: Record; + try { + expected = JSON.parse(canonical) as Record; + if (typeof expected.tag !== 'string') return null; + } catch { + return null; + } + for (const match of raw.matchAll(/("tag"\s*:\s*)("(?:\\.|[^"\\])*")/g)) { + const start = match.index + match[1].length; + const candidate = + raw.slice(0, start) + JSON.stringify(expected.tag) + raw.slice(start + match[2].length); + try { + if (sameJsonValue(JSON.parse(candidate), expected)) return candidate; + } catch { + // Invalid intermediate buffers are replaced with the form's canonical JSON. + } + } + return null; +} + /* * The "All" editor shows the full inbound JSON in one editor: top-level * connection fields plus the three nested sub-objects (settings, @@ -96,7 +137,7 @@ export function AdvancedAllEditor({ streamEnabled: boolean; sniffingEnabled: boolean; }) { - const { control, setValue } = useFormContext(); + const { control, getValues, setValue } = useFormContext(); const wListen = useWatch({ control, name: 'listen' }); const wPort = useWatch({ control, name: 'port' }); const wProtocol = useWatch({ control, name: 'protocol' }); @@ -106,29 +147,30 @@ export function AdvancedAllEditor({ const wStream = useWatch({ control, name: 'streamSettings' }); const serialize = () => { + const current = getValues(); /* * Apply the same prune/normalize as the wire payload so the JSON * shown here is what the panel actually POSTs (no empty defaults, * disabled sniffing as { enabled: false }, finalmask dropped when * there are no masks). */ - const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; - if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { - settingsView.clients = normalizeClients(wProtocol, settingsView.clients); + const settingsView = (pruneEmpty(current.settings ?? {}) ?? {}) as Record; + if (typeof current.protocol === 'string' && Array.isArray(settingsView.clients)) { + settingsView.clients = normalizeClients(current.protocol, settingsView.clients); } const streamView = streamEnabled - ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) + ? ((pruneEmpty(current.streamSettings ?? {}) ?? {}) as Record) : undefined; dropLegacyOptionalEmpties(settingsView, streamView); const out: Record = { - listen: wListen ?? '', - port: wPort ?? 0, - protocol: wProtocol ?? '', - tag: wTag ?? '', + listen: current.listen ?? '', + port: current.port ?? 0, + protocol: current.protocol ?? '', + tag: current.tag ?? '', settings: settingsView, }; if (sniffingEnabled) { - out.sniffing = normalizeSniffing(wSniffing as Parameters[0]); + out.sniffing = normalizeSniffing(current.sniffing as Parameters[0]); } if (streamView) out.streamSettings = streamView; return JSON.stringify(out, null, 2); @@ -144,7 +186,7 @@ export function AdvancedAllEditor({ useEffect(() => { const formStr = serialize(); if (formStr === lastEmitRef.current) return; - setText(formStr); + setText((previous) => updateTagOnly(previous, formStr) ?? formStr); lastEmitRef.current = formStr; /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [ @@ -188,7 +230,9 @@ export function AdvancedAllEditor({ if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { setValue('streamSettings', parsed.streamSettings); } - lastEmitRef.current = next; + const canonical = serialize(); + lastEmitRef.current = canonical; + if (!sameJsonValue(parsed, JSON.parse(canonical))) setText(canonical); }} /> ); diff --git a/frontend/src/test/advanced-all-editor.test.tsx b/frontend/src/test/advanced-all-editor.test.tsx new file mode 100644 index 000000000..50c020f0a --- /dev/null +++ b/frontend/src/test/advanced-all-editor.test.tsx @@ -0,0 +1,126 @@ +import { useEffect } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { AdvancedAllEditor } from '@/pages/inbounds/form/advanced-editors'; + +vi.mock('@/components/form', () => ({ + JsonEditor: ({ value, onChange }: { value: string; onChange: (next: string) => void }) => ( +