advanced-editors.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { useEffect, useRef, useState } from 'react';
  2. import { Form, type FormInstance } from 'antd';
  3. import type { NamePath } from 'antd/es/form/interface';
  4. import { JsonEditor } from '@/components/form';
  5. import {
  6. pruneEmpty,
  7. normalizeSniffing,
  8. normalizeClients,
  9. dropLegacyOptionalEmpties,
  10. } from '@/lib/xray/inbound-form-adapter';
  11. import type { InboundFormValues } from '@/schemas/forms/inbound-form';
  12. // Sub-editor for one slice of the form (settings, streamSettings, sniffing).
  13. // Holds a local text buffer so the user can type freely; on every keystroke
  14. // we try to JSON.parse and forward the result to form state. Invalid JSON
  15. // is held in the buffer until the next valid moment — no panic on partial
  16. // input. The buffer seeds once on mount; the modal's destroyOnHidden makes
  17. // each open a fresh editor instance, so we don't need to re-sync on outer
  18. // form changes.
  19. export function AdvancedSliceEditor({
  20. form,
  21. path,
  22. wrapKey,
  23. minHeight,
  24. maxHeight,
  25. }: {
  26. form: FormInstance<InboundFormValues>;
  27. path: NamePath;
  28. // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
  29. // the JSON the user sees matches the wire shape's slice envelope (e.g.
  30. // `{ "settings": { ... } }`). Edits unwrap the outer key before writing
  31. // back to the form. Mirrors the legacy modal's wrappedConfigValue.
  32. wrapKey?: string;
  33. minHeight?: string;
  34. maxHeight?: string;
  35. }) {
  36. const serialize = (value: unknown): string => {
  37. const inner = value ?? {};
  38. return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
  39. };
  40. // preserve: true so useWatch returns the full subtree from the form
  41. // store — without it, useWatch goes through getFieldsValue() which
  42. // filters out unregistered fields. Slices like `settings` would lose
  43. // their `clients` / `fallbacks` sub-trees because those aren't bound
  44. // to any Form.Item.
  45. const watched = Form.useWatch(path, { form, preserve: true });
  46. const lastEmitRef = useRef<string>('');
  47. const [text, setText] = useState(() => {
  48. const initial = serialize(form.getFieldValue(path));
  49. lastEmitRef.current = initial;
  50. return initial;
  51. });
  52. useEffect(() => {
  53. const formStr = serialize(watched);
  54. if (formStr === lastEmitRef.current) return;
  55. setText(formStr);
  56. lastEmitRef.current = formStr;
  57. // eslint-disable-next-line react-hooks/exhaustive-deps
  58. }, [watched, wrapKey]);
  59. return (
  60. <JsonEditor
  61. value={text}
  62. minHeight={minHeight}
  63. maxHeight={maxHeight}
  64. onChange={(next) => {
  65. setText(next);
  66. try {
  67. const parsed = JSON.parse(next);
  68. const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
  69. ? (parsed as Record<string, unknown>)[wrapKey] ?? {}
  70. : parsed;
  71. form.setFieldValue(path, toWrite);
  72. lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
  73. } catch {
  74. // invalid JSON; keep buffer, don't push to form
  75. }
  76. }}
  77. />
  78. );
  79. }
  80. // The "All" editor shows the full inbound JSON in one editor: top-level
  81. // connection fields plus the three nested sub-objects (settings,
  82. // streamSettings, sniffing). Edits round-trip back to the form's slices,
  83. // mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
  84. // works the same way as AdvancedSliceEditor: useWatch on the slices we
  85. // care about, lastEmitRef as the "we wrote this" guard.
  86. export function AdvancedAllEditor({
  87. form,
  88. streamEnabled,
  89. }: {
  90. form: FormInstance<InboundFormValues>;
  91. streamEnabled: boolean;
  92. }) {
  93. // preserve: true — default useWatch returns only registered fields, so
  94. // sub-trees we never bound (settings.clients/fallbacks, sniffing
  95. // defaults, etc.) wouldn't show up. preserve switches the read to
  96. // getFieldsValue(true) which returns the full form store.
  97. const wListen = Form.useWatch('listen', { form, preserve: true });
  98. const wPort = Form.useWatch('port', { form, preserve: true });
  99. const wProtocol = Form.useWatch('protocol', { form, preserve: true });
  100. const wTag = Form.useWatch('tag', { form, preserve: true });
  101. const wSettings = Form.useWatch('settings', { form, preserve: true });
  102. const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
  103. const wStream = Form.useWatch('streamSettings', { form, preserve: true });
  104. const serialize = () => {
  105. // Apply the same prune/normalize as the wire payload so the JSON
  106. // shown here is what the panel actually POSTs (no empty defaults,
  107. // disabled sniffing as { enabled: false }, finalmask dropped when
  108. // there are no masks).
  109. const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
  110. if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
  111. settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
  112. }
  113. const streamView = streamEnabled
  114. ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
  115. : undefined;
  116. dropLegacyOptionalEmpties(settingsView, streamView);
  117. const out: Record<string, unknown> = {
  118. listen: wListen ?? '',
  119. port: wPort ?? 0,
  120. protocol: wProtocol ?? '',
  121. tag: wTag ?? '',
  122. settings: settingsView,
  123. sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
  124. };
  125. if (streamView) out.streamSettings = streamView;
  126. return JSON.stringify(out, null, 2);
  127. };
  128. const lastEmitRef = useRef<string>('');
  129. const [text, setText] = useState(() => {
  130. const initial = serialize();
  131. lastEmitRef.current = initial;
  132. return initial;
  133. });
  134. useEffect(() => {
  135. const formStr = serialize();
  136. if (formStr === lastEmitRef.current) return;
  137. setText(formStr);
  138. lastEmitRef.current = formStr;
  139. // eslint-disable-next-line react-hooks/exhaustive-deps
  140. }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]);
  141. return (
  142. <JsonEditor
  143. value={text}
  144. minHeight="340px"
  145. maxHeight="560px"
  146. onChange={(next) => {
  147. setText(next);
  148. let parsed: Record<string, unknown>;
  149. try {
  150. parsed = JSON.parse(next) as Record<string, unknown>;
  151. } catch {
  152. return;
  153. }
  154. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
  155. if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
  156. if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
  157. form.setFieldValue('port', parsed.port);
  158. }
  159. if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
  160. if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
  161. if (parsed.settings && typeof parsed.settings === 'object') {
  162. form.setFieldValue('settings', parsed.settings);
  163. }
  164. if (parsed.sniffing && typeof parsed.sniffing === 'object') {
  165. form.setFieldValue('sniffing', parsed.sniffing);
  166. }
  167. if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
  168. form.setFieldValue('streamSettings', parsed.streamSettings);
  169. }
  170. lastEmitRef.current = next;
  171. }}
  172. />
  173. );
  174. }