fix(inbounds): preserve formatting in the advanced JSON editor (#6857)

In the inbound form's Advanced → All editor, pressing Enter or adding any
whitespace while the JSON stayed valid made the panel replace the operator's
text with its own formatting, dropping the new line and moving the caret.

Edits that only change formatting are now left alone, while changes made
elsewhere in the form still reach the editor, a tag change patches only the
tag, and an edit the form rejects (a non-numeric port, a removed section) is
replaced by what the form will actually save, so the editor never shows JSON
the panel will not submit.
This commit is contained in:
Hamed
2026-10-24 06:24:19 +03:30
committed by GitHub
parent 0516a1f87c
commit 5a135d2adb
4 changed files with 228 additions and 13 deletions
@@ -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<string, unknown>;
const b = right as Record<string, unknown>;
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<string, unknown>;
try {
expected = JSON.parse(canonical) as Record<string, unknown>;
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<string, unknown>;
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
const settingsView = (pruneEmpty(current.settings ?? {}) ?? {}) as Record<string, unknown>;
if (typeof current.protocol === 'string' && Array.isArray(settingsView.clients)) {
settingsView.clients = normalizeClients(current.protocol, settingsView.clients);
}
const streamView = streamEnabled
? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
? ((pruneEmpty(current.streamSettings ?? {}) ?? {}) as Record<string, unknown>)
: undefined;
dropLegacyOptionalEmpties(settingsView, streamView);
const out: Record<string, unknown> = {
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<typeof normalizeSniffing>[0]);
out.sniffing = normalizeSniffing(current.sniffing as Parameters<typeof normalizeSniffing>[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);
}}
/>
);