diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 7231425d..985ad0e6 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -5647,9 +5647,17 @@ pub mod texts { pub fn tui_skills_sync_method_title() -> &'static str { if is_chinese() { - "选择同步方式" + "选择 Skills 同步方式" } else { - "Select Sync Method" + "Select Skill Sync Method" + } + } + + pub fn tui_settings_skills_sync_method_label() -> &'static str { + if is_chinese() { + "Skills 同步方式" + } else { + "Skill Sync Method" } } @@ -5702,25 +5710,19 @@ pub mod texts { pub fn tui_skills_sync_method_name(method: crate::services::skill::SyncMethod) -> &'static str { match method { - crate::services::skill::SyncMethod::Auto => { - if is_chinese() { - "自动(优先使用链接,失败时复制)" - } else { - "Automatic (prefer links, fall back to copy)" - } - } - crate::services::skill::SyncMethod::Symlink => { + crate::services::skill::SyncMethod::Auto + | crate::services::skill::SyncMethod::Symlink => { if is_chinese() { - "仅链接" + "软连接" } else { - "Links only" + "Symlink" } } crate::services::skill::SyncMethod::Copy => { if is_chinese() { - "仅复制" + "文件复制" } else { - "Copy only" + "Copy Files" } } } diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 7542878f..4945a5d1 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -504,6 +504,7 @@ pub enum SettingsItem { VisibleAppsMode, VisibleApps, SkillsStorageLocation, + SkillsSyncMethod, OpenClawConfigDir, ManagedAccounts, SkipClaudeOnboarding, @@ -516,7 +517,7 @@ pub enum SettingsItem { } impl SettingsItem { - pub const ALL: [SettingsItem; 16] = [ + pub const ALL: [SettingsItem; 17] = [ SettingsItem::ManagedAccounts, SettingsItem::Language, SettingsItem::Theme, @@ -525,6 +526,7 @@ impl SettingsItem { SettingsItem::VisibleAppsMode, SettingsItem::VisibleApps, SettingsItem::SkillsStorageLocation, + SettingsItem::SkillsSyncMethod, SettingsItem::OpenClawConfigDir, SettingsItem::SkipClaudeOnboarding, SettingsItem::ClaudePluginIntegration, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 4f7b1cb1..5dc1f96b 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -937,6 +937,12 @@ impl App { }; Action::None } + Some(SettingsItem::SkillsSyncMethod) => { + self.overlay = Overlay::SkillsSyncMethodPicker { + selected: sync_method_picker_index(data.skills.sync_method), + }; + Action::None + } Some(SettingsItem::OpenClawConfigDir) => { let buffer = crate::settings::get_settings() .openclaw_config_dir diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index ce145fdf..d8b079ba 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1785,21 +1785,17 @@ pub(crate) fn snippet_picker_app_type(index: usize) -> AppType { app_type_for_picker_index(index) } -#[cfg(test)] -#[allow(dead_code)] pub(crate) fn sync_method_picker_index(method: SyncMethod) -> usize { match method { - SyncMethod::Auto => 0, - SyncMethod::Symlink => 1, - SyncMethod::Copy => 2, + SyncMethod::Auto | SyncMethod::Symlink => 0, + SyncMethod::Copy => 1, } } pub(crate) fn sync_method_for_picker_index(index: usize) -> SyncMethod { match index { - 1 => SyncMethod::Symlink, - 2 => SyncMethod::Copy, - _ => SyncMethod::Auto, + 1 => SyncMethod::Copy, + _ => SyncMethod::Symlink, } } diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index a1339690..6e41040f 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -271,7 +271,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(1); Action::None } KeyCode::Enter => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index c23ef714..6fe261b3 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -10947,6 +10947,71 @@ mod tests { )); } + #[test] + #[serial(home_settings)] + fn settings_skills_sync_method_uses_the_upstream_two_choice_picker() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Settings; + app.focus = Focus::Content; + app.settings_idx = SettingsItem::ALL + .iter() + .position(|item| matches!(item, SettingsItem::SkillsSyncMethod)) + .expect("SkillsSyncMethod missing from SettingsItem::ALL"); + + let mut data = UiData::default(); + data.skills.sync_method = crate::services::skill::SyncMethod::Auto; + + { + let _lang = use_test_language(Language::Chinese); + let help = crate::cli::tui::help::context_help_for_app(&app, &data); + let body = help.lines.join("\n"); + assert_eq!(help.title, "Skills 同步方式"); + assert!(body.contains("选择 Skills 的文件同步策略"), "{body}"); + assert!(body.contains("软连接节省磁盘空间并支持实时同步"), "{body}"); + } + + assert!(matches!( + app.on_key(key(KeyCode::Enter), &data), + Action::None + )); + assert!(matches!( + app.overlay, + Overlay::SkillsSyncMethodPicker { selected: 0 } + )); + + assert!(matches!( + app.on_key(key(KeyCode::Enter), &data), + Action::SkillsSetSyncMethod { + method: crate::services::skill::SyncMethod::Symlink + } + )); + assert!(matches!(app.overlay, Overlay::None)); + + data.skills.sync_method = crate::services::skill::SyncMethod::Symlink; + app.overlay = Overlay::SkillsSyncMethodPicker { selected: 0 }; + assert!(matches!( + app.on_key(key(KeyCode::Down), &data), + Action::None + )); + assert!(matches!( + app.on_key(key(KeyCode::Down), &data), + Action::None + )); + assert!(matches!( + app.overlay, + Overlay::SkillsSyncMethodPicker { selected: 1 } + )); + assert!(matches!( + app.on_key(key(KeyCode::Enter), &data), + Action::SkillsSetSyncMethod { + method: crate::services::skill::SyncMethod::Copy + } + )); + } + #[test] #[serial(home_settings)] fn visible_apps_picker_rejects_zero_selection_without_closing() { diff --git a/src-tauri/src/cli/tui/help.rs b/src-tauri/src/cli/tui/help.rs index a5dc602c..9042f1f7 100644 --- a/src-tauri/src/cli/tui/help.rs +++ b/src-tauri/src/cli/tui/help.rs @@ -53,6 +53,7 @@ enum HelpTarget { FailoverQueue, PreferredEditor, SkillStorageLocation, + SkillSyncMethod, GlobalOutboundProxy, CodexOfficialAuthPreservation, CodexUnifiedSessionHistory, @@ -113,6 +114,7 @@ fn current_help_target(app: &App) -> HelpTarget { provider_local_proxy_overlay_target(app, LocalProxySettingsField::UserAgent) } Overlay::ExternalEditorPicker { .. } => HelpTarget::PreferredEditor, + Overlay::SkillsSyncMethodPicker { .. } => HelpTarget::SkillSyncMethod, Overlay::SkillsStorageLocationPicker { .. } => HelpTarget::SkillStorageLocation, Overlay::ClaudeModelPicker { .. } => { provider_field_overlay_target(app, ProviderAddField::ClaudeModelConfig) @@ -160,6 +162,7 @@ fn current_help_target(app: &App) -> HelpTarget { match SettingsItem::ALL.get(app.settings_idx) { Some(SettingsItem::PreferredEditor) => return HelpTarget::PreferredEditor, Some(SettingsItem::SkillsStorageLocation) => return HelpTarget::SkillStorageLocation, + Some(SettingsItem::SkillsSyncMethod) => return HelpTarget::SkillSyncMethod, Some(SettingsItem::OutboundProxy) => return HelpTarget::GlobalOutboundProxy, Some(SettingsItem::PreserveCodexOfficialAuth) => { return HelpTarget::CodexOfficialAuthPreservation; @@ -346,6 +349,13 @@ fn help_for_target(target: HelpTarget, app: &App, data: &UiData) -> HelpContent "Choose either CC Switch's managed directory or the shared ~/.agents/skills directory as the single source of truth for managed Skills. Switching moves only Skills recorded in the database and refreshes links or copies for enabled apps; unmanaged directories are not imported, claimed, or moved. If Unified contains unmanaged directories, cloud sync refuses to replace the root; import them explicitly or switch back to CC Switch storage first.\nMigration stops and keeps the current setting when the target already has a same-named directory, even with identical content; only an interrupted copy carrying this migration's receipt can resume automatically. If app refresh is partial, the old copy is retained; select the current location again to retry reconciliation. Do not run another Skill install or update command while migration is active.", ), ), + HelpTarget::SkillSyncMethod => HelpContent::new( + texts::tui_settings_skills_sync_method_label(), + help_lines( + "选择 Skills 的文件同步策略。软连接节省磁盘空间并支持实时同步;文件复制适用于不支持软连接的环境。Windows 使用软连接时可能需要管理员权限或开启开发者模式。\n此设置只影响之后的 Skills 部署或同步,不会迁移主存储位置,也不会立即重建现有应用目录。如需立即应用,请在 Skills 页面执行同步。", + "Choose how to sync Skills files. Symlinks save disk space and enable real-time sync; copying files supports environments where symlinks are unavailable. On Windows, symlinks may require administrator privileges or Developer Mode.\nThis setting affects future Skill deployments or syncs only. It does not move the managed storage location or immediately rebuild existing app directories. Run Sync from the Skills page to apply it now.", + ), + ), HelpTarget::GlobalOutboundProxy => HelpContent::new( crate::t!("Global Outbound Proxy", "全局出站代理"), help_lines( diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index e61a912f..9d7f3549 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -21,6 +21,7 @@ fn settings_section(item: SettingsItem) -> SettingsSection { SettingsItem::VisibleAppsMode | SettingsItem::VisibleApps | SettingsItem::SkillsStorageLocation + | SettingsItem::SkillsSyncMethod | SettingsItem::OpenClawConfigDir => SettingsSection::Applications, SettingsItem::SkipClaudeOnboarding | SettingsItem::ClaudePluginIntegration @@ -3487,6 +3488,10 @@ pub(super) fn render_settings( ) .to_string(), ), + super::app::SettingsItem::SkillsSyncMethod => ( + texts::tui_settings_skills_sync_method_label().to_string(), + texts::tui_skills_sync_method_name(data.skills.sync_method).to_string(), + ), super::app::SettingsItem::OpenClawConfigDir => ( texts::tui_settings_openclaw_config_dir_label().to_string(), openclaw_config_dir.clone().unwrap_or_else(|| { diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index c4a8b5ac..12b61709 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -1,6 +1,7 @@ use super::super::theme; use super::super::*; use super::frame::{overlay_frame, overlay_frame_at, OverlaySize}; +use crate::cli::tui::app::sync_method_picker_index; use crate::cli::tui::form; use crate::cli::tui::form::{ClaudeModelRole, HermesModelField, ProviderAddFormState}; use crate::cli::tui::text_edit::TextInput; @@ -2464,7 +2465,6 @@ pub(super) fn render_skills_sync_method_picker_overlay( selected: usize, ) { let methods = [ - crate::services::skill::SyncMethod::Auto, crate::services::skill::SyncMethod::Symlink, crate::services::skill::SyncMethod::Copy, ]; @@ -2475,7 +2475,7 @@ pub(super) fn render_skills_sync_method_picker_overlay( theme, texts::tui_skills_sync_method_title(), &[ - ("←→", texts::tui_key_select()), + ("↑↓", texts::tui_key_select()), ("Enter", texts::tui_key_apply()), ("Esc", texts::tui_key_cancel()), ], @@ -2486,10 +2486,10 @@ pub(super) fn render_skills_sync_method_picker_overlay( overlay_border_style(theme, false), ); - let current = data.skills.sync_method; + let current = sync_method_picker_index(data.skills.sync_method); - let items = methods.into_iter().map(|method| { - let marker = if method == current { + let items = methods.into_iter().enumerate().map(|(index, method)| { + let marker = if index == current { texts::tui_marker_active() } else { texts::tui_marker_inactive() diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index f1f8d8c9..a5afd8c8 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4966,6 +4966,7 @@ fn settings_page_groups_items_with_unlabeled_dividers() { &content, texts::tui_settings_skills_storage_location_label(), ); + let skill_sync = line_index(&content, texts::tui_settings_skills_sync_method_label()); let openclaw_dir = line_index(&content, texts::tui_settings_openclaw_config_dir_label()); let claude_integration = line_index(&content, texts::enable_claude_plugin_integration_label()); let codex_login = line_index(&content, texts::codex_preserve_official_auth_label()); @@ -4980,7 +4981,8 @@ fn settings_page_groups_items_with_unlabeled_dividers() { assert!( dividers[0] < visible_apps && visible_apps < skill_storage - && skill_storage < openclaw_dir + && skill_storage < skill_sync + && skill_sync < openclaw_dir && openclaw_dir < dividers[1], "{content}" ); @@ -5004,6 +5006,30 @@ fn settings_page_groups_items_with_unlabeled_dividers() { } } +#[test] +fn settings_skill_sync_method_matches_upstream_labels_and_choices() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Settings; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + data.skills.sync_method = SyncMethod::Auto; + let buf = render_with_size(&app, &data, 100, 32); + let content = content_text(&app, &buf); + assert!(content.contains("Skill Sync Method"), "{content}"); + assert!(content.contains("Symlink"), "{content}"); + + app.overlay = Overlay::SkillsSyncMethodPicker { selected: 0 }; + let overlay = all_text(&render_with_size(&app, &data, 100, 32)); + assert!(overlay.contains("Symlink"), "{overlay}"); + assert!(overlay.contains("Copy Files"), "{overlay}"); + assert!(!overlay.contains("Automatic"), "{overlay}"); +} + #[test] fn settings_section_rows_do_not_shift_selection_or_break_narrow_scrolling() { let _lock = lock_env();