diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss index a9671297..b0a7c45e 100644 --- a/src/components/config/VisualConfigEditor.module.scss +++ b/src/components/config/VisualConfigEditor.module.scss @@ -140,6 +140,9 @@ .overviewHeader { display: flex; align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; min-width: 0; @include mobile { @@ -172,6 +175,240 @@ background: var(--warning-bg); } +.modeSwitch { + display: inline-flex; + gap: 4px; + padding: 3px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.modeButton { + @include button-reset; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 12px; + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; + line-height: 1.2; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + color: var(--text-primary); + } +} + +.modeButtonActive { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 1px 2px color-mix(in srgb, var(--text-primary) 10%, transparent); +} + +// ── Global field search (P1) ──────────────────────────────────────────────── +.searchBox { + position: relative; + min-width: 0; +} + +.searchControl { + padding-right: 38px !important; +} + +.searchIcon { + display: inline-flex; + align-items: center; + color: var(--text-tertiary); + pointer-events: none; +} + +.searchResults { + position: absolute; + z-index: 20; + top: calc(100% + 6px); + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 320px; + overflow-y: auto; + padding: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + box-shadow: 0 14px 32px color-mix(in srgb, var(--text-primary) 16%, transparent); +} + +.searchResultItem { + @include button-reset; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 9px 10px; + border-radius: 6px; + text-align: left; + transition: background-color 0.12s ease; + + &:hover { + background: color-mix(in srgb, var(--text-primary) 6%, transparent); + } +} + +// Keyboard/pointer "active" option — the aria-activedescendant target. +.searchResultItemActive { + background: color-mix(in srgb, var(--text-primary) 6%, transparent); +} + +.searchResultLabel { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 650; + line-height: 1.3; +} + +.searchResultQualifier { + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.searchResultSection { + flex: 0 0 auto; + color: var(--text-secondary); + font-size: 11px; + font-weight: 650; + white-space: nowrap; +} + +.searchEmpty { + padding: 12px 10px; + color: var(--text-secondary); + font-size: 13px; + text-align: center; +} + +// Stateless wrapper around each searchable field; the search jump scrolls to and +// pulse-highlights it. +.fieldAnchor { + display: block; + min-width: 0; + scroll-margin-top: calc(var(--header-height, 64px) + 16px); +} + +.fieldHighlightActive { + border-radius: 10px; + animation: cfgFieldHighlight 1.8s ease-out; +} + +@keyframes cfgFieldHighlight { + 0% { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 55%, transparent); + } + 55% { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 32%, transparent); + } + 100% { + box-shadow: 0 0 0 3px transparent; + } +} + +.simpleView { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 760px; + min-width: 0; +} + +.simpleForm { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +// Wrap bare inputs so they read as cards consistent with toggleRow / API key blocks. +.simpleField { + padding: 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: transparent; +} + +.simpleBanner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 12px 14px; + border: 1px solid var(--warning-border); + border-radius: 8px; + background: var(--warning-bg); + color: var(--warning-text); + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +.simpleBannerAction { + @include button-reset; + flex: 0 0 auto; + padding: 6px 12px; + border: 1px solid var(--warning-border); + border-radius: 6px; + color: var(--warning-text); + font-size: 12px; + font-weight: 700; + transition: background-color 0.15s ease; + + &:hover { + background: color-mix(in srgb, var(--warning-text) 10%, transparent); + } +} + +.simpleMore { + @include button-reset; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 46px; + padding: 12px 16px; + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; + text-align: center; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--text-primary); + color: var(--text-primary); + background: color-mix(in srgb, var(--text-primary) 4%, transparent); + } +} + .workspace { display: flex; flex-direction: column; diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 9c8b694a..8b90ee43 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -10,15 +10,19 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; +import { Collapsible } from '@/components/ui/Collapsible'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconCode, - IconDiamond, IconKey, + IconNetwork, IconSatellite, - IconSettings, + IconScrollText, + IconSearch, + IconShield, + IconSlidersHorizontal, IconTimer, type IconProps, } from '@/components/ui/icons'; @@ -39,9 +43,17 @@ import { PayloadRulesEditor, StringListEditor, } from './VisualConfigEditorBlocks'; +import { + configFieldDomId, + searchConfigFields, + type ConfigFieldSearchEntry, + type VisualSectionId, +} from './configSearchIndex'; import styles from './VisualConfigEditor.module.scss'; -type VisualSectionId = 'server' | 'auth' | 'system' | 'quota' | 'streaming' | 'payload'; +type EditorMode = 'simple' | 'full'; + +const EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode'; type VisualSection = { id: VisualSectionId; @@ -98,6 +110,16 @@ function Divider() { return
; } +// Stable, stateless anchor around a searchable field. Search jumps target its DOM id +// (see configSearchIndex.ts) and the highlight pulse is applied to it imperatively. +function FieldAnchor({ fieldId, children }: { fieldId: string; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + function SectionSubsection({ title, description, @@ -178,12 +200,140 @@ export function VisualConfigEditor({ const nonstreamKeepaliveInputId = useId(); const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`; const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`; - const [activeSectionId, setActiveSectionId] = useState('server'); + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem(EDITOR_MODE_STORAGE_KEY); + return saved === 'full' ? 'full' : 'simple'; + }); + const [activeSectionId, setActiveSectionId] = useState('connectivity'); const sectionRefs = useRef>>({}); const mobileNavScrollerRef = useRef(null); const mobileNavButtonRefs = useRef>>( {} ); + const [searchQuery, setSearchQuery] = useState(''); + // Dropdown visibility is tracked separately from the query text so a jump can close the + // results while leaving the typed text in the box for further editing. + const [searchOpen, setSearchOpen] = useState(false); + // Highlighted option for keyboard navigation of the results listbox (-1 = none). + const [activeResultIndex, setActiveResultIndex] = useState(0); + const searchListboxId = useId(); + const searchResultsRef = useRef(null); + // A fresh object per jump; the effect handles it once (guarded by handledJumpRef) so it + // never needs to clear state from inside the effect. + const [jumpRequest, setJumpRequest] = useState<{ + fieldId: string; + sectionId: VisualSectionId; + } | null>(null); + const handledJumpRef = useRef<{ fieldId: string; sectionId: VisualSectionId } | null>(null); + const searchBoxRef = useRef(null); + const highlightTimerRef = useRef(null); + const highlightedElRef = useRef(null); + + const handleModeChange = useCallback((next: EditorMode) => { + setMode(next); + localStorage.setItem(EDITOR_MODE_STORAGE_KEY, next); + }, []); + + const searchResults = useMemo(() => searchConfigFields(searchQuery, t), [searchQuery, t]); + // The results popup is visible only when the box is open AND there's a (trimmed) query. + const isResultsOpen = searchOpen && Boolean(searchQuery.trim()); + // Clamp the highlighted index to the current result set so a stale index (e.g. after the + // query narrows the list) never points past the end or at an option that no longer exists. + const effectiveActiveIndex = + searchResults.length > 0 + ? Math.min(Math.max(activeResultIndex, 0), searchResults.length - 1) + : -1; + + const handleResultJump = useCallback( + (entry: ConfigFieldSearchEntry) => { + // Keep the query text so the user can tweak it; just close the results dropdown. + setSearchOpen(false); + handleModeChange('full'); + setActiveSectionId(entry.sectionId); + // A new object instance defers scroll/highlight to the effect below, giving a + // simple→full switch time to mount the DOM before we query for the field. + setJumpRequest({ fieldId: entry.fieldId, sectionId: entry.sectionId }); + }, + [handleModeChange] + ); + + // Imperatively scroll to and pulse-highlight the jumped-to field once full mode is mounted. + useEffect(() => { + if (mode !== 'full' || !jumpRequest || handledJumpRef.current === jumpRequest) return; + handledJumpRef.current = jumpRequest; // handle each request once, even if deps re-fire + const { fieldId, sectionId } = jumpRequest; + const targetFieldId = + (fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable + ? 'tlsEnable' + : fieldId; + + const el = document.getElementById(configFieldDomId(targetFieldId)); + if (!el) { + // Field not rendered right now (e.g. TLS cert while TLS is disabled) — fall back to + // bringing its section into view horizontally. + sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: 'start' }); + return; + } + + // Expand the collapsed
group this field belongs to: an ancestor when the + // anchor sits inside the group (TLS / remote / advanced fields), or a descendant when + // the anchor wraps the whole group (payload rule groups). + const details = el.closest('details') ?? el.querySelector('details'); + if (details && !details.open) details.open = true; + + // Clear any in-flight highlight before starting a new one. + if (highlightTimerRef.current !== null) { + clearTimeout(highlightTimerRef.current); + highlightedElRef.current?.classList.remove(styles.fieldHighlightActive); + } + + // Full-mode sections live in a horizontal scroll-snap container (`scroll-snap-type: x + // mandatory`). A single field-level scrollIntoView() tries to do the horizontal section + // switch AND the vertical field scroll at once, which the snap pulls back / lands wrong. + // So: (1) switch to the target section horizontally and instantly (no smooth → no snap + // fight), then (2) next frame, scroll the field vertically with inline:'nearest' so it + // can't re-trigger horizontal snapping. + sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: 'start' }); + requestAnimationFrame(() => { + el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + el.classList.add(styles.fieldHighlightActive); + }); + highlightedElRef.current = el; + highlightTimerRef.current = window.setTimeout(() => { + el.classList.remove(styles.fieldHighlightActive); + highlightTimerRef.current = null; + highlightedElRef.current = null; + }, 1800); + }, [mode, jumpRequest, values.tlsEnable]); + + // Clear the highlight timer on unmount. + useEffect( + () => () => { + if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); + }, + [] + ); + + // Close the results dropdown (keeping the query) when clicking outside the search box. + useEffect(() => { + if (!searchOpen) return; + const handlePointerDown = (event: MouseEvent) => { + if (searchBoxRef.current && !searchBoxRef.current.contains(event.target as Node)) { + setSearchOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [searchOpen]); + + // Keep the highlighted option scrolled into view during keyboard navigation. + useEffect(() => { + if (!isResultsOpen || effectiveActiveIndex < 0) return; + const node = searchResultsRef.current?.querySelector( + `[data-result-index="${effectiveActiveIndex}"]` + ); + node?.scrollIntoView({ block: 'nearest' }); + }, [effectiveActiveIndex, isResultsOpen]); const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; @@ -270,31 +420,32 @@ export function VisualConfigEditor({ const sections = useMemo( () => [ { - id: 'server', - title: t('config_management.visual.sections.server.title'), - icon: IconSettings, + id: 'connectivity', + title: t('config_management.visual.sections.connectivity.title'), + icon: IconKey, errorCount: countErrors(['port']), }, { - id: 'auth', - title: t('config_management.visual.sections.auth.title'), - icon: IconKey, - errorCount: 0, - }, - { - id: 'system', - title: t('config_management.visual.sections.system.title'), - icon: IconDiamond, + id: 'network', + title: t('config_management.visual.sections.network.title'), + icon: IconNetwork, errorCount: countErrors([ - 'errorLogsMaxFiles', - 'logsMaxTotalSizeMb', - 'redisUsageQueueRetentionSeconds', 'requestRetry', 'maxRetryCredentials', 'maxRetryInterval', 'authAutoRefreshWorkers', ]), }, + { + id: 'logging', + title: t('config_management.visual.sections.logging.title'), + icon: IconScrollText, + errorCount: countErrors([ + 'errorLogsMaxFiles', + 'logsMaxTotalSizeMb', + 'redisUsageQueueRetentionSeconds', + ]), + }, { id: 'quota', title: t('config_management.visual.sections.quota.title'), @@ -311,6 +462,12 @@ export function VisualConfigEditor({ 'streaming.nonstreamKeepaliveInterval', ]), }, + { + id: 'advanced', + title: t('config_management.visual.sections.advanced.title'), + icon: IconShield, + errorCount: 0, + }, { id: 'payload', title: t('config_management.visual.sections.payload.title'), @@ -323,9 +480,16 @@ export function VisualConfigEditor({ const hasValidationIssues = sections.some((section) => section.errorCount > 0) || hasPayloadValidationErrors; + // Validation errors that live in fields not surfaced by simple mode (everything except `port`). + const hasHiddenValidationIssues = + (Object.keys(validationErrors ?? {}) as VisualConfigFieldPath[]).some( + (field) => field !== 'port' && Boolean(validationErrors?.[field]) + ) || hasPayloadValidationErrors; + const payloadValidationKey = hasPayloadValidationErrors ? 'payload-errors' : 'payload-ok'; const activeSection = sections.find((section) => section.id === activeSectionId) ?? sections[0]; useEffect(() => { + if (mode !== 'full') return undefined; if (!isCurrentLayer) return undefined; if (typeof IntersectionObserver === 'undefined') return undefined; @@ -350,10 +514,10 @@ export function VisualConfigEditor({ } return () => observer.disconnect(); - }, [isCurrentLayer, sections]); + }, [isCurrentLayer, mode, sections]); useEffect(() => { - if (!isCurrentLayer || !isMobile) return; + if (mode !== 'full' || !isCurrentLayer || !isMobile) return; const scroller = mobileNavScrollerRef.current; const button = mobileNavButtonRefs.current[activeSectionId]; if (!scroller || !button) return; @@ -371,7 +535,7 @@ export function VisualConfigEditor({ left: targetLeft, behavior: 'smooth', }); - }, [activeSectionId, isCurrentLayer, isMobile]); + }, [activeSectionId, isCurrentLayer, isMobile, mode]); const handleSectionJump = useCallback((sectionId: VisualSectionId) => { setActiveSectionId(sectionId); @@ -382,6 +546,106 @@ export function VisualConfigEditor({ }); }, []); + // Shared high-frequency field blocks — reused verbatim by both the simple view and full mode + // so the two layouts never drift apart. + const hostField = ( + + onChange({ host: e.target.value })} + disabled={disabled} + /> + + ); + + const portField = ( + + onChange({ port: e.target.value })} + disabled={disabled} + error={portError} + /> + + ); + + const proxyUrlField = ( + + onChange({ proxyUrl: e.target.value })} + disabled={disabled} + /> + + ); + + const apiKeysField = ( + +
+ +
+
+ ); + + const debugToggle = ( + + onChange({ debug })} + /> + + ); + + const loggingToFileToggle = ( + + onChange({ loggingToFile })} + /> + + ); + + const quotaSwitchProjectToggle = ( + + onChange({ quotaSwitchProject })} + /> + + ); + + const quotaSwitchPreviewModelToggle = ( + + onChange({ quotaSwitchPreviewModel })} + /> + + ); + const navContent = (
{sections.map((section, index) => { @@ -422,11 +686,33 @@ export function VisualConfigEditor({
+
+ + +
- - {t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })} - - {activeSection?.title} + {mode === 'full' && activeSection ? ( + {activeSection.title} + ) : null} {hasValidationIssues ? ( {t('config_management.visual.validation.validation_blocked')} @@ -434,451 +720,351 @@ export function VisualConfigEditor({ ) : null}
+ +
+ = 0 + ? `${searchListboxId}-opt-${effectiveActiveIndex}` + : undefined + } + value={searchQuery} + onChange={(e) => { + setSearchQuery(e.target.value); + setSearchOpen(true); + setActiveResultIndex(0); + }} + onFocus={() => setSearchOpen(true)} + onKeyDown={(e) => { + // Ignore keys fired while an IME is composing (e.g. picking a Chinese + // candidate) — otherwise candidate selection triggers navigation/jump. + if (e.nativeEvent.isComposing) return; + if (e.key === 'Escape') { + setSearchOpen(false); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isResultsOpen) { + setSearchOpen(true); + return; + } + if (searchResults.length === 0) return; + setActiveResultIndex((effectiveActiveIndex + 1) % searchResults.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!isResultsOpen) { + setSearchOpen(true); + return; + } + if (searchResults.length === 0) return; + setActiveResultIndex( + effectiveActiveIndex <= 0 ? searchResults.length - 1 : effectiveActiveIndex - 1 + ); + return; + } + if (e.key === 'Enter' && isResultsOpen && searchResults.length > 0) { + e.preventDefault(); + handleResultJump(searchResults[effectiveActiveIndex] ?? searchResults[0]); + } + }} + rightElement={ + + } + /> + {isResultsOpen ? ( +
+ {searchResults.length > 0 ? ( + searchResults.map((entry, index) => ( + + )) + ) : ( +
+ {t('config_management.visual.search.no_results')} +
+ )} +
+ ) : null} +
-
- {isMobile ? ( -
-
- {sections.map((section, index) => ( - - ))} + {mode === 'simple' ? ( +
+ {hasHiddenValidationIssues ? ( +
+ {t('config_management.visual.mode.validation_banner')} +
+ ) : null} + +
+
{hostField}
+
{portField}
+ {apiKeysField} +
{proxyUrlField}
+ {debugToggle} + {loggingToFileToggle} + {quotaSwitchProjectToggle} + {quotaSwitchPreviewModelToggle}
- ) : null} - - -
- { - sectionRefs.current.server = node; - }} - indexLabel="01" - icon={} - title={t('config_management.visual.sections.server.title')} - description={t('config_management.visual.sections.server.description')} +
+ ) : ( +
+ {isMobile ? ( +
+
- - onChange({ tlsEnable })} - /> - - {values.tlsEnable ? ( - <> - - - onChange({ tlsCert: e.target.value })} - disabled={disabled} - /> - onChange({ tlsKey: e.target.value })} - disabled={disabled} - /> - - - ) : null} - - - - - - - onChange({ rmAllowRemote })} - /> - onChange({ rmDisableControlPanel })} - /> - - onChange({ rmDisableAutoUpdatePanel }) - } - /> - - - onChange({ rmSecretKey: e.target.value })} - disabled={disabled} - /> - onChange({ rmPanelRepo: e.target.value })} - disabled={disabled} - /> - - - - - - - { - sectionRefs.current.auth = node; - }} - indexLabel="02" - icon={} - title={t('config_management.visual.sections.auth.title')} - description={t('config_management.visual.sections.auth.description')} - > - - onChange({ authDir: e.target.value })} - disabled={disabled} - hint={t('config_management.visual.sections.auth.auth_dir_hint')} - /> -
- + {sections.map((section, index) => ( + + ))}
-
-
+
+ ) : null} - { - sectionRefs.current.system = node; - }} - indexLabel="03" - icon={} - title={t('config_management.visual.sections.system.title')} - description={t('config_management.visual.sections.system.description')} - > - - - onChange({ debug })} - /> - onChange({ commercialMode })} - /> - onChange({ loggingToFile })} - /> - onChange({ pluginsEnabled })} - /> - + - -
- - + { + sectionRefs.current.connectivity = node; + }} + indexLabel="01" + icon={} + title={t('config_management.visual.sections.connectivity.title')} + description={t('config_management.visual.sections.connectivity.description')} + > + + + {hostField} + {portField} + + + + onChange({ authDir: e.target.value })} disabled={disabled} - placeholder={t( - 'config_management.visual.sections.system.plugin_store_sources_placeholder' - )} - inputAriaLabel={t( - 'config_management.visual.sections.system.plugin_store_sources_label' - )} - onChange={handlePluginStoreSourcesChange} + hint={t('config_management.visual.sections.auth.auth_dir_hint')} /> -
- {t('config_management.visual.sections.system.plugin_store_sources_hint')} -
-
-
+ - - onChange({ logsMaxTotalSizeMb: e.target.value })} - disabled={disabled} - error={logsMaxSizeError} - /> - onChange({ errorLogsMaxFiles: e.target.value })} - disabled={disabled} - error={errorLogsMaxFilesError} - /> - onChange({ redisUsageQueueRetentionSeconds: e.target.value })} - disabled={disabled} - hint={t('config_management.visual.sections.system.redis_usage_retention_hint')} - error={redisUsageQueueRetentionError} - /> - - - onChange({ usageStatisticsEnabled })} - /> - - onChange({ antigravitySignatureCacheEnabled }) - } - /> - - onChange({ antigravitySignatureBypassStrict }) - } - /> - + {apiKeysField} - - -
-

- {t('config_management.visual.sections.headers.claude_title')} -

-
- - onChange({ claudeHeaderUserAgent: e.target.value })} - disabled={disabled} - /> - onChange({ claudeHeaderPackageVersion: e.target.value })} - disabled={disabled} - /> - onChange({ claudeHeaderRuntimeVersion: e.target.value })} - disabled={disabled} - /> - onChange({ claudeHeaderOs: e.target.value })} - disabled={disabled} - /> - onChange({ claudeHeaderArch: e.target.value })} - disabled={disabled} - /> - onChange({ claudeHeaderTimeout: e.target.value })} - disabled={disabled} - /> - - - - onChange({ claudeHeaderStabilizeDeviceProfile }) - } - /> - - -
-

- {t('config_management.visual.sections.headers.codex_title')} -

-
- - onChange({ codexHeaderUserAgent: e.target.value })} - disabled={disabled} - /> - onChange({ codexHeaderBetaFeatures: e.target.value })} - disabled={disabled} - /> - - - onChange({ codexIdentityConfuse })} - /> - -
-
+ + + + onChange({ tlsEnable })} + /> + - - - - onChange({ proxyUrl: e.target.value })} - disabled={disabled} - /> + {values.tlsEnable ? ( + <> + + + + onChange({ tlsCert: e.target.value })} + disabled={disabled} + /> + + + onChange({ tlsKey: e.target.value })} + disabled={disabled} + /> + + + + ) : null} + + + + + + + + onChange({ rmAllowRemote })} + /> + + + onChange({ rmDisableControlPanel })} + /> + + + + onChange({ rmDisableAutoUpdatePanel }) + } + /> + + + + + onChange({ rmSecretKey: e.target.value })} + disabled={disabled} + /> + + + onChange({ rmPanelRepo: e.target.value })} + disabled={disabled} + /> + + + + +
+
+ + { + sectionRefs.current.network = node; + }} + indexLabel="02" + icon={} + title={t('config_management.visual.sections.network.title')} + description={t('config_management.visual.sections.network.description')} + > + + + {proxyUrlField} + + + + + + + + + + + + + + + onChange({ routingSessionAffinityTTL: e.target.value })} disabled={disabled} /> - + + - + + onChange({ forceModelPrefix })} /> + + onChange({ passthroughHeaders })} /> + + onChange({ disableCooling })} /> + + onChange({ routingSessionAffinity })} /> + + onChange({ wsAuth })} /> + + onChange({ enableGeminiCliEndpoint })} /> + + + + + + { + sectionRefs.current.logging = node; + }} + indexLabel="03" + icon={} + title={t('config_management.visual.sections.logging.title')} + description={t('config_management.visual.sections.logging.description')} + > + + + {debugToggle} + + onChange({ commercialMode })} + /> + + {loggingToFileToggle} + + + + + onChange({ logsMaxTotalSizeMb: e.target.value })} + disabled={disabled} + error={logsMaxSizeError} + /> + + + onChange({ errorLogsMaxFiles: e.target.value })} + disabled={disabled} + error={errorLogsMaxFilesError} + /> + + + + onChange({ redisUsageQueueRetentionSeconds: e.target.value }) + } + disabled={disabled} + hint={t( + 'config_management.visual.sections.system.redis_usage_retention_hint' + )} + error={redisUsageQueueRetentionError} + /> + + + + + + onChange({ usageStatisticsEnabled })} + /> + + + + + + { + sectionRefs.current.quota = node; + }} + indexLabel="04" + icon={} + title={t('config_management.visual.sections.quota.title')} + description={t('config_management.visual.sections.quota.description')} + > + + {quotaSwitchProjectToggle} + {quotaSwitchPreviewModelToggle} + + onChange({ quotaAntigravityCredits })} + /> + + + + + { + sectionRefs.current.streaming = node; + }} + indexLabel="05" + icon={} + title={t('config_management.visual.sections.streaming.title')} + description={t('config_management.visual.sections.streaming.description')} + > + + + + +
+ + onChange({ + streaming: { + ...values.streaming, + keepaliveSeconds: e.target.value, + }, + }) + } + disabled={disabled} + /> + {isKeepaliveDisabled ? ( + + {t('config_management.visual.sections.streaming.disabled')} + + ) : null} +
+
+
+ + + + onChange({ + streaming: { + ...values.streaming, + bootstrapRetries: e.target.value, + }, + }) + } + disabled={disabled} + hint={t('config_management.visual.sections.streaming.bootstrap_hint')} + error={bootstrapRetriesError} + /> + +
+ + + + +
+ + onChange({ + streaming: { + ...values.streaming, + nonstreamKeepaliveInterval: e.target.value, + }, + }) + } + disabled={disabled} + /> + {isNonstreamKeepaliveDisabled ? ( + + {t('config_management.visual.sections.streaming.disabled')} + + ) : null} +
+
+
+
+
+
+ + { + sectionRefs.current.advanced = node; + }} + indexLabel="06" + icon={} + title={t('config_management.visual.sections.advanced.title')} + description={t('config_management.visual.sections.advanced.description')} + > + + + + + + onChange({ pluginsEnabled })} + /> + + + + + +
+ + +
+ {t( + 'config_management.visual.sections.system.plugin_store_sources_hint' + )} +
+
+
+
+
+
+ + + + + + onChange({ antigravitySignatureCacheEnabled }) + } + /> + + + + onChange({ antigravitySignatureBypassStrict }) + } + /> + -
- - -
+ - { - sectionRefs.current.quota = node; - }} - indexLabel="04" - icon={} - title={t('config_management.visual.sections.quota.title')} - description={t('config_management.visual.sections.quota.description')} - > - - onChange({ quotaSwitchProject })} - /> - onChange({ quotaSwitchPreviewModel })} - /> - onChange({ quotaAntigravityCredits })} - /> - - - - { - sectionRefs.current.streaming = node; - }} - indexLabel="05" - icon={} - title={t('config_management.visual.sections.streaming.title')} - description={t('config_management.visual.sections.streaming.description')} - > - - - -
- - onChange({ - streaming: { - ...values.streaming, - keepaliveSeconds: e.target.value, - }, - }) - } + +
+

+ {t('config_management.visual.sections.headers.claude_title')} +

+
+ + + onChange({ claudeHeaderUserAgent: e.target.value })} + disabled={disabled} + /> + + + onChange({ claudeHeaderPackageVersion: e.target.value })} + disabled={disabled} + /> + + + onChange({ claudeHeaderRuntimeVersion: e.target.value })} + disabled={disabled} + /> + + + onChange({ claudeHeaderOs: e.target.value })} + disabled={disabled} + /> + + + onChange({ claudeHeaderArch: e.target.value })} + disabled={disabled} + /> + + + onChange({ claudeHeaderTimeout: e.target.value })} + disabled={disabled} + /> + + + + + + onChange({ claudeHeaderStabilizeDeviceProfile }) + } + /> + + + +
+

+ {t('config_management.visual.sections.headers.codex_title')} +

+
+ + + onChange({ codexHeaderUserAgent: e.target.value })} + disabled={disabled} + /> + + + onChange({ codexHeaderBetaFeatures: e.target.value })} + disabled={disabled} + /> + + + + + onChange({ codexIdentityConfuse })} + /> + + +
+ + + + + { + sectionRefs.current.payload = node; + }} + indexLabel="07" + icon={} + title={t('config_management.visual.sections.payload.title')} + description={t('config_management.visual.sections.payload.description')} + > + + + + - {isKeepaliveDisabled ? ( - - {t('config_management.visual.sections.streaming.disabled')} - - ) : null} -
-
+ + - - onChange({ - streaming: { - ...values.streaming, - bootstrapRetries: e.target.value, - }, - }) - } - disabled={disabled} - hint={t('config_management.visual.sections.streaming.bootstrap_hint')} - error={bootstrapRetriesError} - /> -
- - - -
- - onChange({ - streaming: { - ...values.streaming, - nonstreamKeepaliveInterval: e.target.value, - }, - }) - } + + + - {isNonstreamKeepaliveDisabled ? ( - - {t('config_management.visual.sections.streaming.disabled')} - - ) : null} -
-
-
-
-
+ + - { - sectionRefs.current.payload = node; - }} - indexLabel="06" - icon={} - title={t('config_management.visual.sections.payload.title')} - description={t('config_management.visual.sections.payload.description')} - > - - - - + + + + + - - - + + + + + - - - - - - - - - - - - - + + + + + + + +
-
+ )}
); } diff --git a/src/components/config/configSearchIndex.ts b/src/components/config/configSearchIndex.ts new file mode 100644 index 00000000..2536eb3c --- /dev/null +++ b/src/components/config/configSearchIndex.ts @@ -0,0 +1,499 @@ +// Search index for the visual config editor's global "jump to field" search. +// +// IMPORTANT: this index is maintained by hand and is NOT what drives field +// rendering — it only powers search. When you add, remove, or move a field in +// VisualConfigEditor.tsx, update the matching entry here (and wrap the field's +// JSX in using the same `fieldId`). + +export type VisualSectionId = + | 'connectivity' + | 'network' + | 'logging' + | 'quota' + | 'streaming' + | 'advanced' + | 'payload'; + +export interface ConfigFieldSearchEntry { + /** Stable anchor id; matches FieldAnchor's `fieldId` and the rendered DOM id. */ + fieldId: string; + sectionId: VisualSectionId; + /** i18n key resolved with t() at search time so matching follows the active language. */ + labelKey: string; + /** Optional secondary i18n key shown next to the label to disambiguate duplicates + * (e.g. Claude vs Codex "User-Agent"). Also searchable. */ + qualifierKey?: string; + /** Optional hint i18n key — searchable but not shown in results. */ + hintKey?: string; + /** Backend YAML key aliases, e.g. ['proxy-url']. Static strings (language-agnostic). */ + yamlKeys?: string[]; + /** Extra synonyms to match against (language-agnostic, lowercase). */ + keywords?: string[]; +} + +/** DOM id for a field anchor — kept in one place so the index and the anchors agree. */ +export const configFieldDomId = (fieldId: string) => `cfg-field-${fieldId}`; + +type Translate = (key: string) => string; + +// Compact helper: every label/hint key lives under config_management.visual. +const L = (key: string) => `config_management.visual.${key}`; + +export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [ + // ── connectivity ────────────────────────────────────────────────────────── + { + fieldId: 'host', + sectionId: 'connectivity', + labelKey: L('sections.server.host'), + yamlKeys: ['host'], + }, + { + fieldId: 'port', + sectionId: 'connectivity', + labelKey: L('sections.server.port'), + yamlKeys: ['port'], + }, + { + fieldId: 'authDir', + sectionId: 'connectivity', + labelKey: L('sections.auth.auth_dir'), + hintKey: L('sections.auth.auth_dir_hint'), + yamlKeys: ['auth-dir'], + }, + { + fieldId: 'apiKeys', + sectionId: 'connectivity', + labelKey: L('api_keys.label'), + yamlKeys: ['api-keys'], + keywords: ['api key', 'apikey', 'token'], + }, + { + fieldId: 'tlsEnable', + sectionId: 'connectivity', + labelKey: L('sections.tls.enable'), + hintKey: L('sections.tls.enable_desc'), + yamlKeys: ['tls'], + keywords: ['tls', 'ssl', 'https'], + }, + { + fieldId: 'tlsCert', + sectionId: 'connectivity', + labelKey: L('sections.tls.cert'), + yamlKeys: ['tls', 'cert'], + keywords: ['tls', 'ssl', 'certificate'], + }, + { + fieldId: 'tlsKey', + sectionId: 'connectivity', + labelKey: L('sections.tls.key'), + yamlKeys: ['tls', 'key'], + keywords: ['tls', 'ssl', 'private key'], + }, + { + fieldId: 'rmAllowRemote', + sectionId: 'connectivity', + labelKey: L('sections.remote.allow_remote'), + hintKey: L('sections.remote.allow_remote_desc'), + yamlKeys: ['remote-management', 'allow-remote'], + }, + { + fieldId: 'rmDisableControlPanel', + sectionId: 'connectivity', + labelKey: L('sections.remote.disable_panel'), + yamlKeys: ['remote-management', 'disable-control-panel'], + }, + { + fieldId: 'rmDisableAutoUpdatePanel', + sectionId: 'connectivity', + labelKey: L('sections.remote.disable_auto_update_panel'), + yamlKeys: ['remote-management', 'disable-auto-update-panel'], + }, + { + fieldId: 'rmSecretKey', + sectionId: 'connectivity', + labelKey: L('sections.remote.secret_key'), + yamlKeys: ['remote-management', 'secret-key'], + }, + { + fieldId: 'rmPanelRepo', + sectionId: 'connectivity', + labelKey: L('sections.remote.panel_repo'), + yamlKeys: ['remote-management', 'panel-github-repository'], + }, + // ── network ─────────────────────────────────────────────────────────────── + { + fieldId: 'proxyUrl', + sectionId: 'network', + labelKey: L('sections.network.proxy_url'), + yamlKeys: ['proxy-url'], + }, + { + fieldId: 'requestRetry', + sectionId: 'network', + labelKey: L('sections.network.request_retry'), + yamlKeys: ['request-retry'], + }, + { + fieldId: 'maxRetryCredentials', + sectionId: 'network', + labelKey: L('sections.network.max_retry_credentials'), + hintKey: L('sections.network.max_retry_credentials_hint'), + yamlKeys: ['max-retry-credentials'], + }, + { + fieldId: 'maxRetryInterval', + sectionId: 'network', + labelKey: L('sections.network.max_retry_interval'), + yamlKeys: ['max-retry-interval'], + }, + { + fieldId: 'authAutoRefreshWorkers', + sectionId: 'network', + labelKey: L('sections.network.auth_auto_refresh_workers'), + hintKey: L('sections.network.auth_auto_refresh_workers_hint'), + yamlKeys: ['auth-auto-refresh-workers'], + }, + { + fieldId: 'routingStrategy', + sectionId: 'network', + labelKey: L('sections.network.routing_strategy'), + hintKey: L('sections.network.routing_strategy_hint'), + yamlKeys: ['routing', 'strategy'], + keywords: ['round-robin', 'fill-first'], + }, + { + fieldId: 'disableImageGeneration', + sectionId: 'network', + labelKey: L('sections.network.disable_image_generation'), + hintKey: L('sections.network.disable_image_generation_hint'), + yamlKeys: ['disable-image-generation'], + }, + { + fieldId: 'gptImage2BaseModel', + sectionId: 'network', + labelKey: L('sections.network.gpt_image_2_base_model'), + hintKey: L('sections.network.gpt_image_2_base_model_hint'), + yamlKeys: ['gpt-image-2-base-model'], + }, + { + fieldId: 'routingSessionAffinityTTL', + sectionId: 'network', + labelKey: L('sections.network.session_affinity_ttl'), + yamlKeys: ['routing', 'session-affinity-ttl'], + }, + { + fieldId: 'forceModelPrefix', + sectionId: 'network', + labelKey: L('sections.network.force_model_prefix'), + hintKey: L('sections.network.force_model_prefix_desc'), + yamlKeys: ['force-model-prefix'], + }, + { + fieldId: 'passthroughHeaders', + sectionId: 'network', + labelKey: L('sections.network.passthrough_headers'), + hintKey: L('sections.network.passthrough_headers_desc'), + yamlKeys: ['passthrough-headers'], + }, + { + fieldId: 'disableCooling', + sectionId: 'network', + labelKey: L('sections.network.disable_cooling'), + hintKey: L('sections.network.disable_cooling_desc'), + yamlKeys: ['disable-cooling'], + }, + { + fieldId: 'routingSessionAffinity', + sectionId: 'network', + labelKey: L('sections.network.session_affinity'), + yamlKeys: ['routing', 'session-affinity'], + }, + { + fieldId: 'wsAuth', + sectionId: 'network', + labelKey: L('sections.network.ws_auth'), + hintKey: L('sections.network.ws_auth_desc'), + yamlKeys: ['ws-auth'], + keywords: ['websocket'], + }, + { + fieldId: 'enableGeminiCliEndpoint', + sectionId: 'network', + labelKey: L('sections.network.enable_gemini_cli_endpoint'), + hintKey: L('sections.network.enable_gemini_cli_endpoint_desc'), + yamlKeys: ['enable-gemini-cli-endpoint'], + }, + // ── logging ─────────────────────────────────────────────────────────────── + { + fieldId: 'debug', + sectionId: 'logging', + labelKey: L('sections.system.debug'), + hintKey: L('sections.system.debug_desc'), + yamlKeys: ['debug'], + }, + { + fieldId: 'commercialMode', + sectionId: 'logging', + labelKey: L('sections.system.commercial_mode'), + hintKey: L('sections.system.commercial_mode_desc'), + yamlKeys: ['commercial-mode'], + }, + { + fieldId: 'loggingToFile', + sectionId: 'logging', + labelKey: L('sections.system.logging_to_file'), + hintKey: L('sections.system.logging_to_file_desc'), + yamlKeys: ['logging-to-file'], + }, + { + fieldId: 'logsMaxTotalSizeMb', + sectionId: 'logging', + labelKey: L('sections.system.logs_max_size'), + yamlKeys: ['logs-max-total-size-mb'], + }, + { + fieldId: 'errorLogsMaxFiles', + sectionId: 'logging', + labelKey: L('sections.system.error_logs_max_files'), + yamlKeys: ['error-logs-max-files'], + }, + { + fieldId: 'redisUsageQueueRetentionSeconds', + sectionId: 'logging', + labelKey: L('sections.system.redis_usage_retention'), + hintKey: L('sections.system.redis_usage_retention_hint'), + yamlKeys: ['redis-usage-queue-retention-seconds'], + }, + { + fieldId: 'usageStatisticsEnabled', + sectionId: 'logging', + labelKey: L('sections.system.usage_statistics_enabled'), + hintKey: L('sections.system.usage_statistics_enabled_desc'), + yamlKeys: ['usage-statistics-enabled'], + }, + // ── quota ───────────────────────────────────────────────────────────────── + { + fieldId: 'quotaSwitchProject', + sectionId: 'quota', + labelKey: L('sections.quota.switch_project'), + hintKey: L('sections.quota.switch_project_desc'), + yamlKeys: ['quota-exceeded', 'switch-project'], + }, + { + fieldId: 'quotaSwitchPreviewModel', + sectionId: 'quota', + labelKey: L('sections.quota.switch_preview_model'), + hintKey: L('sections.quota.switch_preview_model_desc'), + yamlKeys: ['quota-exceeded', 'switch-preview-model'], + }, + { + fieldId: 'quotaAntigravityCredits', + sectionId: 'quota', + labelKey: L('sections.quota.antigravity_credits'), + yamlKeys: ['quota-exceeded', 'antigravity-credits'], + }, + // ── streaming ───────────────────────────────────────────────────────────── + { + fieldId: 'streamingKeepaliveSeconds', + sectionId: 'streaming', + labelKey: L('sections.streaming.keepalive_seconds'), + hintKey: L('sections.streaming.keepalive_hint'), + yamlKeys: ['streaming', 'keepalive-seconds'], + }, + { + fieldId: 'streamingBootstrapRetries', + sectionId: 'streaming', + labelKey: L('sections.streaming.bootstrap_retries'), + hintKey: L('sections.streaming.bootstrap_hint'), + yamlKeys: ['streaming', 'bootstrap-retries'], + }, + { + fieldId: 'streamingNonstreamKeepalive', + sectionId: 'streaming', + labelKey: L('sections.streaming.nonstream_keepalive'), + hintKey: L('sections.streaming.nonstream_keepalive_hint'), + yamlKeys: ['streaming', 'nonstream-keepalive-interval'], + }, + // ── advanced ────────────────────────────────────────────────────────────── + { + fieldId: 'pluginsEnabled', + sectionId: 'advanced', + labelKey: L('sections.system.plugins_enabled'), + hintKey: L('sections.system.plugins_enabled_desc'), + yamlKeys: ['plugins'], + }, + { + fieldId: 'pluginStoreSources', + sectionId: 'advanced', + labelKey: L('sections.system.plugin_store_sources'), + hintKey: L('sections.system.plugin_store_sources_hint'), + yamlKeys: ['plugins', 'store-sources'], + }, + { + fieldId: 'antigravitySignatureCacheEnabled', + sectionId: 'advanced', + labelKey: L('sections.system.antigravity_signature_cache'), + hintKey: L('sections.system.antigravity_signature_cache_desc'), + yamlKeys: ['antigravity-signature-cache-enabled'], + }, + { + fieldId: 'antigravitySignatureBypassStrict', + sectionId: 'advanced', + labelKey: L('sections.system.antigravity_signature_strict'), + hintKey: L('sections.system.antigravity_signature_strict_desc'), + yamlKeys: ['antigravity-signature-bypass-strict'], + }, + // Claude header defaults — qualifierKey disambiguates the shared "User-Agent" label. + { + fieldId: 'claudeHeaderUserAgent', + sectionId: 'advanced', + labelKey: L('sections.headers.user_agent'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'user-agent'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderPackageVersion', + sectionId: 'advanced', + labelKey: L('sections.headers.package_version'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'package-version'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderRuntimeVersion', + sectionId: 'advanced', + labelKey: L('sections.headers.runtime_version'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'runtime-version'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderOs', + sectionId: 'advanced', + labelKey: L('sections.headers.os'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'os'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderArch', + sectionId: 'advanced', + labelKey: L('sections.headers.arch'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'arch'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderTimeout', + sectionId: 'advanced', + labelKey: L('sections.headers.timeout'), + qualifierKey: L('sections.headers.claude_title'), + yamlKeys: ['claude-header-defaults', 'timeout'], + keywords: ['claude'], + }, + { + fieldId: 'claudeHeaderStabilizeDeviceProfile', + sectionId: 'advanced', + labelKey: L('sections.headers.stabilize_device'), + qualifierKey: L('sections.headers.claude_title'), + hintKey: L('sections.headers.stabilize_device_desc'), + yamlKeys: ['claude-header-defaults', 'stabilize-device-profile'], + keywords: ['claude'], + }, + // Codex header defaults. + { + fieldId: 'codexHeaderUserAgent', + sectionId: 'advanced', + labelKey: L('sections.headers.user_agent'), + qualifierKey: L('sections.headers.codex_title'), + yamlKeys: ['codex-header-defaults', 'user-agent'], + keywords: ['codex'], + }, + { + fieldId: 'codexHeaderBetaFeatures', + sectionId: 'advanced', + labelKey: L('sections.headers.beta_features'), + qualifierKey: L('sections.headers.codex_title'), + yamlKeys: ['codex-header-defaults', 'beta-features'], + keywords: ['codex'], + }, + { + fieldId: 'codexIdentityConfuse', + sectionId: 'advanced', + labelKey: L('sections.headers.codex_identity_confuse'), + qualifierKey: L('sections.headers.codex_title'), + hintKey: L('sections.headers.codex_identity_confuse_desc'), + yamlKeys: ['codex-header-defaults', 'identity-confuse'], + keywords: ['codex'], + }, + // ── payload (coarse: one entry per rule group) ────────────────────────────── + { + fieldId: 'payloadDefaultRules', + sectionId: 'payload', + labelKey: L('sections.payload.default_rules'), + hintKey: L('sections.payload.default_rules_desc'), + keywords: ['payload', 'rule'], + }, + { + fieldId: 'payloadDefaultRawRules', + sectionId: 'payload', + labelKey: L('sections.payload.default_raw_rules'), + hintKey: L('sections.payload.default_raw_rules_desc'), + keywords: ['payload', 'rule', 'json'], + }, + { + fieldId: 'payloadOverrideRules', + sectionId: 'payload', + labelKey: L('sections.payload.override_rules'), + hintKey: L('sections.payload.override_rules_desc'), + keywords: ['payload', 'rule'], + }, + { + fieldId: 'payloadOverrideRawRules', + sectionId: 'payload', + labelKey: L('sections.payload.override_raw_rules'), + hintKey: L('sections.payload.override_raw_rules_desc'), + keywords: ['payload', 'rule', 'json'], + }, + { + fieldId: 'payloadFilterRules', + sectionId: 'payload', + labelKey: L('sections.payload.filter_rules'), + hintKey: L('sections.payload.filter_rules_desc'), + keywords: ['payload', 'rule', 'filter'], + }, +]; + +const MAX_RESULTS = 8; + +/** + * Lowercase substring search over label + qualifier + hint + YAML keys + keywords. + * Returns the best ~8 matches, label/qualifier hits ranked above alias-only hits. + */ +export function searchConfigFields(query: string, t: Translate): ConfigFieldSearchEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return []; + + const scored: { entry: ConfigFieldSearchEntry; score: number }[] = []; + + for (const entry of CONFIG_FIELD_SEARCH_INDEX) { + const label = t(entry.labelKey).toLowerCase(); + const qualifier = entry.qualifierKey ? t(entry.qualifierKey).toLowerCase() : ''; + const hint = entry.hintKey ? t(entry.hintKey).toLowerCase() : ''; + const yaml = (entry.yamlKeys ?? []).join(' ').toLowerCase(); + const keywords = (entry.keywords ?? []).join(' ').toLowerCase(); + + let score = Number.POSITIVE_INFINITY; + if (label.startsWith(q)) score = 0; + else if (label.includes(q)) score = 1; + else if (qualifier.includes(q) || keywords.includes(q)) score = 2; + else if (yaml.includes(q)) score = 3; + else if (hint.includes(q)) score = 4; + + if (Number.isFinite(score)) scored.push({ entry, score }); + } + + scored.sort((a, b) => a.score - b.score); + return scored.slice(0, MAX_RESULTS).map((item) => item.entry); +} diff --git a/src/components/ui/Collapsible/Collapsible.tsx b/src/components/ui/Collapsible/Collapsible.tsx index 03d646ae..1325e5ce 100644 --- a/src/components/ui/Collapsible/Collapsible.tsx +++ b/src/components/ui/Collapsible/Collapsible.tsx @@ -1,4 +1,9 @@ -import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; +import { + useState, + type HTMLAttributes, + type PropsWithChildren, + type ReactNode, +} from 'react'; import { IconChevronDown } from '../icons'; import styles from './Collapsible.module.scss'; @@ -22,12 +27,23 @@ export function Collapsible({ className, ...rest }: PropsWithChildren) { - const detailsProps = - open !== undefined ? { open } : { defaultOpen }; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const resolvedOpen = open ?? uncontrolledOpen; const cls = [styles.root, className].filter(Boolean).join(' '); const contentCls = flush ? styles.contentFlush : styles.content; + return ( -
+
{ + if (open === undefined) { + setUncontrolledOpen(event.currentTarget.open); + } + onToggle?.(event); + }} + {...rest} + > {label} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2b1c8697..f9ede766 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -909,7 +909,33 @@ "visual": { "notice": "Visual mode covers common fields. Review or edit unsupported config.yaml entries in source mode.", "quick_jump": "Quick Jump", + "mode": { + "simple": "Simple", + "full": "Full", + "label": "Editor mode", + "more_settings": "Switch to full mode to see all {{total}} sections", + "validation_banner": "Some advanced settings have validation errors and are blocking save. Switch to full mode to fix them.", + "switch_to_full": "Switch to full mode" + }, + "search": { + "placeholder": "Search settings (label or YAML key)", + "no_results": "No matching settings" + }, "sections": { + "connectivity": { + "title": "Access & Authentication", + "description": "Server address, port, auth directory, and API keys" + }, + "logging": { + "title": "Logging & Diagnostics", + "description": "Debug, log output, and usage statistics" + }, + "advanced": { + "title": "Advanced & Experimental", + "description": "Header spoofing, signature cache, plugin sources, and other rarely changed settings", + "plugins_title": "Plugins", + "signature_title": "Antigravity Signature" + }, "server": { "title": "Server Configuration", "description": "Basic server settings", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ad3f1647..86cd97c8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -896,7 +896,33 @@ "visual": { "notice": "Визуальный режим охватывает основные поля. Остальные параметры config.yaml по-прежнему нужно проверять или редактировать в режиме исходника.", "quick_jump": "Быстрый переход", + "mode": { + "simple": "Простой", + "full": "Полный", + "label": "Режим редактора", + "more_settings": "Переключитесь в полный режим, чтобы увидеть все разделы ({{total}})", + "validation_banner": "В некоторых дополнительных параметрах есть ошибки проверки, сохранение заблокировано. Переключитесь в полный режим, чтобы исправить их.", + "switch_to_full": "Перейти в полный режим" + }, + "search": { + "placeholder": "Поиск настроек (по названию или ключу YAML)", + "no_results": "Нет подходящих настроек" + }, "sections": { + "connectivity": { + "title": "Доступ и аутентификация", + "description": "Адрес сервера, порт, каталог аутентификации и ключи API" + }, + "logging": { + "title": "Логи и диагностика", + "description": "Отладка, вывод логов и статистика использования" + }, + "advanced": { + "title": "Дополнительно и эксперименты", + "description": "Подмена заголовков, кэш подписей, источники плагинов и другие редко изменяемые настройки", + "plugins_title": "Плагины", + "signature_title": "Подпись Antigravity" + }, "server": { "title": "Настройки сервера", "description": "Базовые параметры сервера", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 34d5032e..0bc00c59 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -909,7 +909,33 @@ "visual": { "notice": "可视化模式覆盖常用字段,未覆盖的配置仍需在源文件模式中查看或编辑。", "quick_jump": "快速跳转", + "mode": { + "simple": "简单", + "full": "完整", + "label": "编辑模式", + "more_settings": "切换到完整模式,查看全部 {{total}} 个配置分区", + "validation_banner": "部分高级配置项存在校验错误,已阻止保存。请切换到完整模式进行修复。", + "switch_to_full": "切换完整模式" + }, + "search": { + "placeholder": "搜索配置项(标签或 YAML 键名)", + "no_results": "没有匹配的配置项" + }, "sections": { + "connectivity": { + "title": "接入与认证", + "description": "服务地址、端口、认证目录与 API 密钥" + }, + "logging": { + "title": "日志与诊断", + "description": "调试、日志输出与使用统计" + }, + "advanced": { + "title": "高级与实验", + "description": "请求头伪装、签名缓存、插件源等不常修改的设置", + "plugins_title": "插件", + "signature_title": "Antigravity 签名" + }, "server": { "title": "服务器配置", "description": "基础服务器设置", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 546d6de4..8606e90a 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -935,7 +935,33 @@ "visual": { "notice": "視覺化模式涵蓋常用欄位,未涵蓋的設定仍需在原始檔模式中查看或編輯。", "quick_jump": "快速跳轉", + "mode": { + "simple": "簡單", + "full": "完整", + "label": "編輯模式", + "more_settings": "切換到完整模式,檢視全部 {{total}} 個設定分區", + "validation_banner": "部分進階設定項存在驗證錯誤,已阻止儲存。請切換到完整模式進行修正。", + "switch_to_full": "切換完整模式" + }, + "search": { + "placeholder": "搜尋設定項(標籤或 YAML 鍵名)", + "no_results": "沒有符合的設定項" + }, "sections": { + "connectivity": { + "title": "接入與認證", + "description": "服務位址、連接埠、認證目錄與 API 金鑰" + }, + "logging": { + "title": "日誌與診斷", + "description": "除錯、日誌輸出與使用統計" + }, + "advanced": { + "title": "進階與實驗", + "description": "請求標頭偽裝、簽章快取、外掛來源等不常修改的設定", + "plugins_title": "外掛", + "signature_title": "Antigravity 簽章" + }, "server": { "title": "伺服器設定", "description": "基本伺服器設定",