From fdf56ee7eb466ad8cc33518824a895cbab0aff69 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 2 Oct 2026 19:58:46 +0800 Subject: [PATCH] feat(config): redesign settings navigation and preserve v7 config migration - reorganize advanced settings with horizontal navigation, search, and contextual help - remove LAN discovery and trusted proxy settings - add robust v7-to-v8 migration for custom, plugin, routing, TLS, and credential fields - preserve unsaved drafts during external configuration refresh - add migration and settings UI coverage --- package.json | 1 + src-tauri/src/core_config/extended.rs | 53 +- src-tauri/src/core_config/yaml.rs | 158 +++ src-tauri/src/core_runtime.rs | 6 +- src-tauri/src/tests/core_runtime.rs | 40 +- src/components/SettingsHelp.tsx | 67 ++ src/components/StructuredConfigEditor.tsx | 3 +- src/components/TemplateConfigSection.tsx | 43 +- src/mocks/browserMockRuntime.ts | 2 +- src/pages/ConfigPanel.tsx | 1019 +++++++++---------- src/pages/SensitiveWordsPage.tsx | 4 +- src/pages/ThinkingAliasesPage.tsx | 10 +- src/services/generalTemplateFields.ts | 21 - src/services/settingsNavigation.ts | 53 + src/styles/config-settings.css | 103 ++ src/styles/index.css | 1 + src/styles/template-config.css | 60 +- tests/app-shell-accessibility-ui.cjs | 42 + tests/config-settings-ui.cjs | 408 ++++++++ tests/coreOperations.v8.integration.test.ts | 2 +- tests/responsive-layout-ui.cjs | 23 +- tests/templateConfig.test.ts | 10 +- 22 files changed, 1497 insertions(+), 632 deletions(-) create mode 100644 src/components/SettingsHelp.tsx create mode 100644 src/services/settingsNavigation.ts create mode 100644 src/styles/config-settings.css create mode 100644 tests/config-settings-ui.cjs diff --git a/package.json b/package.json index 0555ef8..aeed5d9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs", "test:ui:api-access-layout": "node tests/api-access-layout-ui.cjs", "test:ui:responsive": "node tests/responsive-layout-ui.cjs", + "test:ui:settings": "node tests/config-settings-ui.cjs", "screenshots": "node scripts/capture-readme-screenshots.cjs", "tauri": "tauri" }, diff --git a/src-tauri/src/core_config/extended.rs b/src-tauri/src/core_config/extended.rs index 9a95b92..724dc49 100644 --- a/src-tauri/src/core_config/extended.rs +++ b/src-tauri/src/core_config/extended.rs @@ -65,10 +65,8 @@ const PATHS: &[(&str, &str)] = &[ ("oauth/providers/xai", "xai"), ("server/host", "host"), ("server/port", "port"), - ("server/trusted-proxies", "trusted-proxies"), ("server/tls", "tls"), ("server/commercial-mode", "commercial-mode"), - ("server/discovery", "discovery"), ("management", "remote-management"), ("credentials/concurrency", "credential-concurrency"), ("credentials/in-flight", "credential-in-flight"), @@ -139,6 +137,18 @@ const PATHS: &[(&str, &str)] = &[ ("observability/pprof", "pprof"), ]; +pub(crate) fn legacy_extended_config_paths() -> Vec<(Vec<&'static str>, Vec<&'static str>)> { + PATHS + .iter() + .map(|(native, legacy)| { + ( + legacy.split('/').collect::>(), + native.split('/').collect::>(), + ) + }) + .collect() +} + fn parts(path: &str) -> Vec { path.split('/').map(str::to_owned).collect() } @@ -285,8 +295,6 @@ fn legacy_path(path: &[String]) -> Vec { // Only template-backed settings are writable. Existing GUI-owned settings and // system metadata cannot be changed by this command, even via a parent object. const OBJECT_PATHS: &[&str] = &[ - "server/discovery", - "server/discovery/interfaces", "credentials/concurrency", "credentials/in-flight", "client/codex", @@ -306,11 +314,6 @@ const OBJECT_PATHS: &[&str] = &[ "oauth/providers/xai", ]; const FIELD_GROUPS: &[(&str, &str, &str)] = &[ - ("server", "strings", "trusted-proxies"), - ("server/discovery", "bool", "enabled auth-required advertise-management"), - ("server/discovery", "string", "service-name service-type"), - ("server/discovery", "strings", "subtypes"), - ("server/discovery/interfaces", "strings", "include exclude"), ("management", "bool", "allow-remote disable-control-panel disable-auto-update-panel"), ("management", "string", "base-url panel-github-repository"), ("credentials/concurrency", "string", "cpa-heartbeat-timeout cpa-cancel-bound reclaim-grace cleanup-interval release-flush-interval release-max-backoff busy-retry-min busy-retry-max"), @@ -421,17 +424,6 @@ fn duration_nanos(value: &str) -> Option { (end == text.len() && nanos < i64::MAX as f64) .then(|| (nanos as i64) * if value.starts_with('-') { -1 } else { 1 }) } -fn valid_ip_or_cidr(value: &str) -> bool { - if let Some((address, prefix)) = value.split_once('/') { - address.parse::().is_ok_and(|address| { - prefix - .parse::() - .is_ok_and(|prefix| prefix <= if address.is_ipv4() { 32 } else { 128 }) - }) - } else { - value.parse::().is_ok() - } -} fn valid_ice_url(value: &str) -> bool { let Some((scheme, address)) = value.split_once(':') else { return false; @@ -722,10 +714,9 @@ fn validate_value(path: &[String], value: &Json, before: &Json) -> Result<(), St Some("strings") if value.as_array().is_some_and(|items| { items.iter().all(|value| { - value.as_str().is_some_and(|text| { - !text.chars().any(char::is_control) - && (key != "server/trusted-proxies" || valid_ip_or_cidr(text)) - }) + value + .as_str() + .is_some_and(|text| !text.chars().any(char::is_control)) }) }) => {} Some("image-mode") @@ -1075,7 +1066,6 @@ fn prepare_patch( "server/host", "server/port", "server/tls", - "server/trusted-proxies", "management", "oauth/auth-dir", ] @@ -1242,7 +1232,7 @@ client: {codex: {optimize-multi-agent-v2: false}} json!(true), false, ), - change(content, "server/trusted-proxies", json!([]), false), + change(content, "management/allow-remote", json!(true), false), ]; let (rendered, result) = prepare_patch(content, &changes).unwrap(); assert!(rendered.contains("# keep heading")); @@ -1404,7 +1394,6 @@ client: {codex: {optimize-multi-agent-v2: false}} "999999999999999999" )); let rejected = [ - ("server/trusted-proxies", json!(["192.0.2.1/40"])), ("observability/pprof/addr", json!("bad-address")), ( "oauth/providers/codex/live-media-relay/public-ip", @@ -1449,7 +1438,6 @@ client: {codex: {optimize-multi-agent-v2: false}} ); } let accepted = [ - ("server/trusted-proxies", json!(["192.0.2.1/24", "::1/128"])), ("observability/pprof/addr", json!("[::1]:8316")), ( "oauth/providers/codex/live-media-relay/public-ip", @@ -1526,8 +1514,6 @@ client: {codex: {optimize-multi-agent-v2: false}} fn extended_restart_hint_distinguishes_live_updates_from_startup_settings() { let content = "config-version: 8\n"; for (path, value) in [ - ("server/discovery", json!({"enabled": true})), - ("server/discovery/service-name", json!("office")), ( "observability/pprof", json!({"enable": true, "addr": "127.0.0.1:8316"}), @@ -1541,7 +1527,6 @@ client: {codex: {optimize-multi-agent-v2: false}} assert!(!result.restart_required, "{path} supports live updates"); } for (path, value) in [ - ("server/trusted-proxies", json!(["127.0.0.1"])), ("management/allow-remote", json!(true)), ("oauth/auth-dir", json!("~/selected-oauth")), ] { @@ -1549,13 +1534,13 @@ client: {codex: {optimize-multi-agent-v2: false}} prepare_patch(content, &[change(content, path, value, false)]).unwrap(); assert!(result.restart_required, "{path} requires a restart"); } - let unchanged = "config-version: 8\nserver: {trusted-proxies: []}\n"; + let unchanged = "config-version: 8\nmanagement: {allow-remote: false}\n"; let (_, result) = prepare_patch( unchanged, &[change( unchanged, - "server/trusted-proxies", - json!([]), + "management/allow-remote", + json!(false), false, )], ) diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index e55bc80..d8bc18d 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -1189,6 +1189,14 @@ pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool { || yaml_mapping_value(root, "api-keys").is_some_and(serde_norway::Value::is_mapping) } +pub(crate) fn core_config_declares_v8(document: &serde_norway::Value) -> bool { + document + .as_mapping() + .and_then(|root| yaml_mapping_value(root, "config-version")) + .and_then(serde_norway::Value::as_u64) + .is_some_and(|version| version >= 8) +} + pub(crate) fn set_core_yaml_path_value( document: &mut serde_norway::Value, path: &[&str], @@ -1219,6 +1227,156 @@ pub(crate) fn set_core_yaml_path_value( Ok(true) } +pub(crate) fn migrate_legacy_core_config_to_v8( + template: &str, + legacy: &str, +) -> Result { + let template_document = serde_norway::from_str::(template) + .map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?; + if !core_config_uses_v8(&template_document) { + return Err("New kernel configuration template is not v8".to_string()); + } + let legacy_document = serde_norway::from_str::(legacy) + .map_err(|error| format!("Failed to parse existing kernel configuration: {error}"))?; + if core_config_declares_v8(&legacy_document) { + return Ok(legacy.to_string()); + } + let legacy_root = legacy_document + .as_mapping() + .ok_or_else(|| "Existing kernel configuration root must be a YAML mapping".to_string())?; + let mut migrated = template_document.clone(); + // Start from the new template so newly introduced defaults remain available, + // then overlay the complete legacy document. The explicit lifts below only + // move fields whose spelling changed between schemas; all other user-owned + // sections must survive the upgrade as-is. + merge_yaml_values(&mut migrated, legacy_document.clone()); + if let Some(version) = yaml_mapping_value( + template_document + .as_mapping() + .ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?, + "config-version", + ).cloned() { + set_core_yaml_path_value(&mut migrated, &["config-version"], version)?; + } else { + remove_core_yaml_path_value(&mut migrated, &["config-version"]); + } + lift_legacy_core_config_fields( + &mut migrated, + legacy_root, + template_document + .as_mapping() + .ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?, + )?; + render_yaml_value_changes(template, &template_document, &migrated) +} + +fn lift_legacy_core_config_fields( + document: &mut serde_norway::Value, + legacy: &serde_norway::Mapping, + template: &serde_norway::Mapping, +) -> Result<(), String> { + let copy = |document: &mut serde_norway::Value, legacy_path: &[&str], v8_path: &[&str]| -> Result<(), String> { + let Some(value) = nested_yaml_value(legacy, legacy_path).cloned() else { + return Ok(()); + }; + // A partially migrated file can contain both spellings. Prefer the + // canonical v8 value in that case; otherwise move the legacy value. + if nested_yaml_value(legacy, v8_path).is_none() { + set_core_yaml_path_value(document, v8_path, value)?; + } + if nested_yaml_value(template, legacy_path).is_none() { + remove_core_yaml_path_value(document, legacy_path); + } + Ok(()) + }; + for (legacy_path, v8_path) in [ + (&["host"][..], &["server", "host"][..]), + (&["port"], &["server", "port"]), + (&["commercial-mode"], &["server", "commercial-mode"]), + (&["auth-dir"], &["oauth", "auth-dir"]), + (&["debug"], &["observability", "logs", "debug"]), + (&["logging-to-file"], &["observability", "logs", "logging-to-file"]), + (&["logs-max-total-size-mb"], &["observability", "logs", "logs-max-total-size-mb"]), + (&["error-logs-max-files"], &["observability", "logs", "error-logs-max-files"]), + (&["request-log"], &["observability", "logs", "request-log"]), + (&["usage-statistics-enabled"], &["observability", "usage", "usage-statistics-enabled"]), + (&["redis-usage-queue-retention-seconds"], &["observability", "usage", "redis-usage-queue-retention-seconds"]), + (&["proxy-url"], &["requests", "proxy-url"]), + (&["disable-cooling"], &["routing", "cooldown", "disable-cooling"]), + (&["request-retry"], &["routing", "retry", "request-retry"]), + (&["max-retry-credentials"], &["routing", "retry", "max-retry-credentials"]), + (&["max-retry-interval"], &["routing", "retry", "max-retry-interval"]), + (&["streaming", "bootstrap-retries"], &["requests", "streaming", "bootstrap-retries"]), + (&["remote-management", "secret-key"], &["management", "secret-key"]), + (&["oauth-model-alias"], &["oauth", "model-alias"]), + (&["payload"], &["requests", "payload"]), + ] { + copy(document, legacy_path, v8_path)?; + } + if let Some(tls) = yaml_mapping_value(legacy, "tls").filter(|value| value.is_mapping()).cloned() { + if nested_yaml_value(legacy, &["server", "tls"]).is_none() { + set_core_yaml_path_value(document, &["server", "tls"], tls)?; + } + if yaml_mapping_value(template, "tls").is_none() { + remove_core_yaml_path_value(document, &["tls"]); + } + } + if let Some(keys) = yaml_mapping_value(legacy, "api-keys").filter(|value| value.is_sequence()).cloned() { + if nested_yaml_value(legacy, &["access", "api-keys"]).is_none() { + set_core_yaml_path_value(document, &["access", "api-keys"], keys)?; + } + if let Some(template_upstreams) = yaml_mapping_value(template, "api-keys") + .filter(|value| value.is_mapping()) + .cloned() + { + // A v8 template may already contain the upstream credential map at + // the root. The legacy client-key sequence must not replace it. + set_core_yaml_path_value(document, &["api-keys"], template_upstreams)?; + } else { + remove_core_yaml_path_value(document, &["api-keys"]); + } + } + let has_canonical_upstream_mapping = yaml_mapping_value(legacy, "api-keys") + .is_some_and(serde_norway::Value::is_mapping); + for (legacy_name, provider) in V8_PROVIDER_FAMILIES { + let Some(records) = yaml_mapping_value(legacy, legacy_name) else { + continue; + }; + if !has_canonical_upstream_mapping + && nested_yaml_value(legacy, &["api-keys", provider]).is_none() + { + let grouped = group_legacy_provider_records(provider, records)?; + set_core_yaml_path_value(document, &["api-keys", provider], grouped)?; + } + if yaml_mapping_value(template, legacy_name).is_none() { + remove_core_yaml_path_value(document, &[legacy_name]); + } + } + for (legacy_path, v8_path) in legacy_extended_config_paths() { + copy(document, &legacy_path, &v8_path)?; + } + Ok(()) +} + +fn remove_core_yaml_path_value(document: &mut serde_norway::Value, path: &[&str]) -> bool { + let Some((key, parents)) = path.split_last() else { + return false; + }; + let Some(mut mapping) = document.as_mapping_mut() else { + return false; + }; + for section in parents { + let Some(parent) = mapping.get_mut(yaml_key(section)) else { + return false; + }; + let Some(parent) = parent.as_mapping_mut() else { + return false; + }; + mapping = parent; + } + mapping.remove(yaml_key(key)).is_some() +} + pub(crate) fn set_core_yaml_schema_value( document: &mut serde_norway::Value, legacy_path: &[&str], diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 9dfd6cf..288dc33 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1957,7 +1957,11 @@ pub(crate) fn migrate_core_config_for_update( } let template_document = serde_norway::from_str::(&template) .map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?; - let migrated = if core_config_uses_v8(&template_document) { + let migrated = if (core_config_declares_v8(&template_document) || core_config_uses_v8(&template_document)) + && !core_config_declares_v8(&old_document) + { + migrate_legacy_core_config_to_v8(&template, &old_config)? + } else if core_config_uses_v8(&template_document) { old_config } else { merge_core_config_fields(&template, Some(&old_config))? diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index bd46fab..0cce9a7 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -594,20 +594,36 @@ fn replacing_a_core_migrates_old_fields_into_the_new_template() { } #[test] -fn replacing_v7_with_v8_preserves_the_legacy_config_byte_for_byte() { +fn replacing_v7_with_v8_lifts_legacy_values_into_the_new_layout() { let root = agent_test_home("core-config-v8-preserve"); let source = root.join("source"); let target = root.join("target"); fs::create_dir_all(&source).unwrap(); fs::create_dir_all(&target).unwrap(); - let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\n"; - let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\nrequests: {proxy-url: ''}\n"; + let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\nrouting:\n strategy: fill-first\n session-affinity: true\n session-affinity-ttl: 2h\nplugins:\n configs:\n local:\n enabled: true\n store-auth:\n registry: stored-token\n"; + let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\napi-keys: {codex: [{name: template-upstream, keys: []}]}\nrequests: {proxy-url: ''}\nrouting: {strategy: round-robin, session-affinity: false, session-affinity-ttl: 30m}\nplugins: {configs: {}, store-auth: {}}\n"; fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap(); fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), v8_template).unwrap(); migrate_core_config_for_update(&source, &target).unwrap(); - assert_eq!(fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(), old_config); + let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(); + let document = serde_norway::from_str::(&migrated).unwrap(); + let settings = core_config_settings_from_value(&document).unwrap(); + assert_eq!(settings.host, "127.0.0.1"); + assert_eq!(settings.port, 9527); + assert_eq!(settings.api_keys, vec!["client-key"]); + assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret")); + assert_eq!(settings.proxy_url, "direct"); + assert_eq!(document["routing"]["strategy"], "fill-first"); + assert_eq!(document["routing"]["session-affinity"], true); + assert_eq!(document["routing"]["session-affinity-ttl"], "2h"); + assert_eq!(document["plugins"]["configs"]["local"]["enabled"], true); + assert_eq!(document["plugins"]["store-auth"]["registry"], "stored-token"); + assert_eq!(document["api-keys"]["codex"][0]["name"], "template-upstream"); + assert!(migrated.starts_with("config-version:")); + assert!(document.get("host").is_none()); + assert!(document.get("port").is_none()); fs::remove_dir_all(root).unwrap(); } @@ -623,16 +639,17 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { let legacy = "# User configuration\nusage-statistics-enabled: true\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n"; let template = "observability: {usage: {usage-statistics-enabled: false}}\nserver: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n"; - for prefix in ["", "config-version: 8\n", "server: {host: 127.0.0.1}\n", "config-version: 8\nserver: {host: 127.0.0.1}\n"] { + for prefix in ["", "server: {host: 127.0.0.1}\n"] { let original = format!("{prefix}{legacy}"); fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap(); for template_prefix in ["config-version: 8\n", ""] { fs::create_dir_all(&staging_dir).unwrap(); fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), format!("{template_prefix}{template}")).unwrap(); migrate_core_config_for_update(&install_dir, &staging_dir).unwrap(); + let migrated = fs::read_to_string(staging_dir.join(CORE_CONFIG_FILE)).unwrap(); overlay_install_dir(&install_dir, &staging_dir).unwrap(); + fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap(); - let migrated = fs::read_to_string(install_dir.join(CORE_CONFIG_FILE)).unwrap(); let document = serde_norway::from_str::(&migrated).unwrap(); let settings = core_config_settings_from_value(&document).unwrap(); assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update"); @@ -642,7 +659,12 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret")); assert_eq!(settings.proxy_url, "direct"); assert_eq!(settings.request_retry, 9); - assert_eq!(migrated, original, "update must preserve every existing field and comment"); + assert!(migrated.contains("server:"), "legacy configuration must be lifted into the v8 layout: {migrated}"); + assert!(document.get("host").is_none(), "{migrated}"); + assert!(document.get("auth-dir").is_none()); + assert!(document.get("codex-api-key").is_none()); + assert_eq!(document["oauth"]["model-alias"]["codex"][0]["alias"], "custom-model"); + assert_eq!(document["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-key"); let mut gui = GuiConfigFile::default(); apply_core_settings_to_gui_config(&mut gui, &settings); @@ -652,8 +674,8 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { let effective = core_config_settings_from_value(&startup).unwrap(); assert_eq!(auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), root.join("oauth")); assert!(effective.usage_statistics_enabled); - assert_eq!(startup["codex-api-key"], document["codex-api-key"]); - assert_eq!(startup["oauth-model-alias"], document["oauth-model-alias"]); + assert_eq!(startup["api-keys"]["codex"], document["api-keys"]["codex"]); + assert_eq!(startup["oauth"]["model-alias"], document["oauth"]["model-alias"]); assert_eq!(fs::read(&credential_path).unwrap(), b"existing-credential"); } } diff --git a/src/components/SettingsHelp.tsx b/src/components/SettingsHelp.tsx new file mode 100644 index 0000000..ec22303 --- /dev/null +++ b/src/components/SettingsHelp.tsx @@ -0,0 +1,67 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { CircleHelp } from 'lucide-react'; +import { useI18n } from '../i18n'; + +export function SettingsHelp({ label, children }: { label: string; children: ReactNode }) { + const { locale } = useI18n(); + const helpLabel = locale === 'zh-CN' ? '说明' : locale === 'ja' ? '説明' : 'Help'; + const id = useId(); + const trigger = useRef(null); + const popover = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + if (!trigger.current || !popover.current) return; + const anchor = trigger.current.getBoundingClientRect(); + // Close help when its setting is hidden by a category or search change. + if (!anchor.width || !anchor.height) { setOpen(false); return; } + const bounds = popover.current.getBoundingClientRect(); + const left = Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12)); + const below = anchor.bottom + 8; + const top = Math.max(12, below + bounds.height <= window.innerHeight - 12 ? below : anchor.top - bounds.height - 8); + setPosition({ left, top }); + }; + place(); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + const observer = new ResizeObserver(place); + if (trigger.current) observer.observe(trigger.current); + return () => { + observer.disconnect(); + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const dismiss = (event: PointerEvent) => { + if (!trigger.current?.contains(event.target as Node) && !popover.current?.contains(event.target as Node)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus({ preventScroll: true }); + } + }; + document.addEventListener('pointerdown', dismiss); + window.addEventListener('keydown', escape, true); + return () => { + document.removeEventListener('pointerdown', dismiss); + window.removeEventListener('keydown', escape, true); + }; + }, [open]); + + return + + {open ? createPortal({children}, document.body) : null} + ; +} diff --git a/src/components/StructuredConfigEditor.tsx b/src/components/StructuredConfigEditor.tsx index 2483c4d..56fafbb 100644 --- a/src/components/StructuredConfigEditor.tsx +++ b/src/components/StructuredConfigEditor.tsx @@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Plus, Trash2, Undo2 } from 'lucide-react'; import { useI18n } from '../i18n'; import { templateText } from '../i18n/templateConfig'; import { configRecord, defaultStructuredValue, type ConfigShape, type ConfigText } from '../services/structuredConfig'; +import { SettingsHelp } from './SettingsHelp'; import './StructuredConfigEditor.css'; export function useConfigText() { @@ -75,11 +76,11 @@ export function StructuredConfigEditor({ shape, value, onChange, disabled = fals return
{Object.entries(shape.fields ?? {}).map(([key, field]) =>
+ {field.hint && {tx(field.hint)}} {field.optional && record[key] !== undefined && }
{field.optional && record[key] == null ? : update(key, next)} disabled={disabled} id={`${id}-${key}`} />} - {field.hint && {tx(field.hint)}}
)}
; } diff --git a/src/components/TemplateConfigSection.tsx b/src/components/TemplateConfigSection.tsx index 95fb88d..92ef140 100644 --- a/src/components/TemplateConfigSection.tsx +++ b/src/components/TemplateConfigSection.tsx @@ -2,19 +2,23 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; -import { RotateCcw, Save, Settings2 } from 'lucide-react'; +import { RotateCcw, Save } from 'lucide-react'; import { useI18n } from '../i18n'; import { templateMessages, templateText } from '../i18n/templateConfig'; import { useCoreRuntime, type CoreStatus } from '../coreRuntime'; +import { SettingsHelp } from './SettingsHelp'; import { applyTemplateChanges, draftTemplateField, readTemplatePath, templateFieldKey, templateFieldValidation, templateFieldSaveValue, - type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult, + type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult, type TemplateText, } from '../services/templateConfig'; import '../styles/template-config.css'; -export function TemplateConfigSection({ groups, visibleGroups }: { +const restartHint = { zh: '需重启内核', en: 'Core restart required', ja: 'コアの再起動が必要' } satisfies TemplateText; + +export function TemplateConfigSection({ groups, visibleGroups, onDirtyGroupsChange }: { groups: readonly TemplateConfigGroup[]; visibleGroups?: readonly string[]; + onDirtyGroupsChange?: (groupIds: readonly string[]) => void; }) { const { locale } = useI18n(); const { status, publishStatus, refreshStatus } = useCoreRuntime(); @@ -27,8 +31,18 @@ export function TemplateConfigSection({ groups, visibleGroups }: { const [busy, setBusy] = useState(null); const mounted = useRef(true); const revision = useRef(0); + const reportedDirtyGroups = useRef(null); const tr = (message: keyof typeof templateMessages) => templateText(templateMessages[message], locale); + useEffect(() => { + if (!onDirtyGroupsChange) return; + const dirtyGroups = groups.filter((group) => group.fields.some((field) => drafts[templateFieldKey(field.path)])).map((group) => group.id); + const signature = JSON.stringify(dirtyGroups); + if (reportedDirtyGroups.current === signature) return; + reportedDirtyGroups.current = signature; + onDirtyGroupsChange(dirtyGroups); + }, [drafts, groups, onDirtyGroupsChange]); + const reload = useCallback(async () => { const request = ++revision.current; try { @@ -132,9 +146,9 @@ export function TemplateConfigSection({ groups, visibleGroups }: { const control = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, id: string, invalid: boolean) => { const disabled = busy !== null || config === null; const onChange = (next: unknown) => changeField(group, field, next); - const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': `${id}-description` }; + const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': invalid ? `${id}-error` : undefined }; if (field.type === 'custom' && field.render) return field.render({ value, onChange, disabled, id }); - if (field.type === 'boolean') return ; + if (field.type === 'boolean') return ; if (field.type === 'select') { const selected = field.options?.findIndex((option) => Object.is(option.value, value)) ?? -1; return setSettingsSearch(event.currentTarget.value)} onKeyDown={event => { if (event.key === 'Escape') setSettingsSearch(''); }} />{searching ? : null} + +
+
+
+ {settingsCategories.map(item => { + const Icon = CATEGORY_ICONS[item.id]; + const dirty = categoryDirty(item.id); + return ; + })} +
+
+
+ {searching ?

{st('results')}

{searchResults.length}
{searchResults.length ?
{searchResults.map(entry => )}
:

{st('noResults')}

}
: <> + {views.length ?
{views.map(view => )}
: null} + } +
+ -
+ - -
-
-
-
-
- -

- {templateText(templateMessages.diagnostics, locale)} -

- -
-
- -
- {t('config.diagnostics.debug.title')} - {t('config.diagnostics.debug.description')} -
- -
- -
- -
- {t('config.diagnostics.commercial.title')} - {t('config.diagnostics.commercial.description')} -
- -
- -
- -
- {t('config.diagnostics.fileLogging.title')} - {t('config.diagnostics.fileLogging.description')} -
- -
- -
- -
- {t('config.diagnostics.usage.title')} - {t('config.diagnostics.usage.description')} -
- -
-
- -
- - - -
- - {commercialModeDraft ? ( -
-
- ) : null} - -
- - {loggingError || ' '} - -
- - -
- {renderFeedback(loggingFeedback)} -
-
-
- ) : activeSubpage === 'network' || activeSubpage === 'routing' ? ( -
-
-
- +
+ + - - -
- ) : activeSubpage === 'software' ? ( -
-
+