From c00f431d67cad99b261b6f7aab1d88bf454a6d2f Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 26 Jan 2026 15:46:51 +0800 Subject: [PATCH] feat(opencode): sync all providers to live config on directory change Add additive mode support for OpenCode in sync_current_to_live: - Add AppType::is_additive_mode() to distinguish switch vs additive mode - Add AppType::all() iterator to avoid hardcoding app lists - Add sync_all_providers_to_live() for additive mode apps - Refactor sync_current_to_live to handle both modes Frontend changes (directory settings): - Track opencodeDirChanged in useDirectorySettings - Trigger syncCurrentProvidersLiveSafe when OpenCode dir changes - Add i18n strings for OpenCode directory settings --- src-tauri/src/app_config.rs | 19 ++++++ src-tauri/src/services/provider/live.rs | 60 +++++++++++++++---- src/components/settings/DirectorySettings.tsx | 13 ++++ src/components/settings/SettingsPage.tsx | 1 + src/hooks/useDirectorySettings.ts | 60 ++++++++++++++++--- src/hooks/useSettings.ts | 20 ++++++- src/hooks/useSettingsForm.ts | 4 ++ src/i18n/locales/en.json | 3 + src/i18n/locales/ja.json | 3 + src/i18n/locales/zh.json | 3 + tests/hooks/useDirectorySettings.test.tsx | 21 +++++-- tests/hooks/useSettings.test.tsx | 1 + 12 files changed, 180 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 9b4a7b0e9..530f6a798 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -282,6 +282,25 @@ impl AppType { AppType::OpenCode => "opencode", } } + + /// Check if this app uses additive mode + /// + /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini) + /// - Additive mode (true): All providers are written to live config (OpenCode) + pub fn is_additive_mode(&self) -> bool { + matches!(self, AppType::OpenCode) + } + + /// Return an iterator over all app types + pub fn all() -> impl Iterator { + [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] + .into_iter() + } } impl FromStr for AppType { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 54ff9d61a..02e55c0a7 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -182,33 +182,67 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re Ok(()) } +/// Sync all providers to live configuration (for additive mode apps) +/// +/// Writes all providers from the database to the live configuration file. +/// Used for OpenCode and other additive mode applications. +fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> { + let providers = state.db.get_all_providers(app_type.as_str())?; + + for provider in providers.values() { + if let Err(e) = write_live_snapshot(app_type, provider) { + log::warn!( + "Failed to sync {:?} provider '{}' to live: {e}", + app_type, + provider.id + ); + // Continue syncing other providers, don't abort + } + } + + log::info!( + "Synced {} {:?} providers to live config", + providers.len(), + app_type + ); + Ok(()) +} + /// Sync current provider to live configuration /// /// 使用有效的当前供应商 ID(验证过存在性)。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。 +/// +/// For additive mode apps (OpenCode), all providers are synced instead of just the current one. pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { - for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { - // Use validated effective current provider - let current_id = - match crate::settings::get_effective_current_provider(&state.db, &app_type)? { - Some(id) => id, - None => continue, - }; + // Sync providers based on mode + for app_type in AppType::all() { + if app_type.is_additive_mode() { + // Additive mode: sync ALL providers + sync_all_providers_to_live(state, &app_type)?; + } else { + // Switch mode: sync only current provider + let current_id = + match crate::settings::get_effective_current_provider(&state.db, &app_type)? { + Some(id) => id, + None => continue, + }; - let providers = state.db.get_all_providers(app_type.as_str())?; - if let Some(provider) = providers.get(¤t_id) { - write_live_snapshot(&app_type, provider)?; + let providers = state.db.get_all_providers(app_type.as_str())?; + if let Some(provider) = providers.get(¤t_id) { + write_live_snapshot(&app_type, provider)?; + } + // Note: get_effective_current_provider already validates existence, + // so providers.get() should always succeed here } - // Note: get_effective_current_provider already validates existence, - // so providers.get() should always succeed here } // MCP sync McpService::sync_all_enabled(state)?; // Skill sync - for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app_type in AppType::all() { if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) { log::warn!("同步 Skill 到 {app_type:?} 失败: {e}"); // Continue syncing other apps, don't abort diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index d33b2d776..4a375fbfb 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -15,6 +15,7 @@ interface DirectorySettingsProps { claudeDir?: string; codexDir?: string; geminiDir?: string; + opencodeDir?: string; onDirectoryChange: (app: AppId, value?: string) => void; onBrowseDirectory: (app: AppId) => Promise; onResetDirectory: (app: AppId) => Promise; @@ -29,6 +30,7 @@ export function DirectorySettings({ claudeDir, codexDir, geminiDir, + opencodeDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, @@ -117,6 +119,17 @@ export function DirectorySettings({ onBrowse={() => onBrowseDirectory("gemini")} onReset={() => onResetDirectory("gemini")} /> + + onDirectoryChange("opencode", val)} + onBrowse={() => onBrowseDirectory("opencode")} + onReset={() => onResetDirectory("opencode")} + /> ); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 1167110fd..650977be2 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -307,6 +307,7 @@ export function SettingsPage({ claudeDir={settings.claudeConfigDir} codexDir={settings.codexConfigDir} geminiDir={settings.geminiConfigDir} + opencodeDir={settings.opencodeConfigDir} onDirectoryChange={updateDirectory} onBrowseDirectory={browseDirectory} onResetDirectory={resetDirectory} diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 3904a5755..42e63ef9d 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,13 +5,14 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; -type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini"; +type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode"; export interface ResolvedDirectories { appConfig: string; claude: string; codex: string; gemini: string; + opencode: string; } const sanitizeDir = (value?: string | null): string | undefined => { @@ -39,7 +40,13 @@ const computeDefaultConfigDir = async ( try { const home = await homeDir(); const folder = - app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini"; + app === "claude" + ? ".claude" + : app === "codex" + ? ".codex" + : app === "gemini" + ? ".gemini" + : ".config/opencode"; return await join(home, folder); } catch (error) { console.error( @@ -70,6 +77,7 @@ export interface UseDirectorySettingsResult { claudeDir?: string, codexDir?: string, geminiDir?: string, + opencodeDir?: string, ) => void; } @@ -96,6 +104,7 @@ export function useDirectorySettings({ claude: "", codex: "", gemini: "", + opencode: "", }); const [isLoading, setIsLoading] = useState(true); @@ -104,6 +113,7 @@ export function useDirectorySettings({ claude: "", codex: "", gemini: "", + opencode: "", }); const initialAppConfigDirRef = useRef(undefined); @@ -119,19 +129,23 @@ export function useDirectorySettings({ claudeDir, codexDir, geminiDir, + opencodeDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, defaultGeminiDir, + defaultOpencodeDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), settingsApi.getConfigDir("codex"), settingsApi.getConfigDir("gemini"), + settingsApi.getConfigDir("opencode"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), computeDefaultConfigDir("gemini"), + computeDefaultConfigDir("opencode"), ]); if (!active) return; @@ -143,6 +157,7 @@ export function useDirectorySettings({ claude: defaultClaudeDir ?? "", codex: defaultCodexDir ?? "", gemini: defaultGeminiDir ?? "", + opencode: defaultOpencodeDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -153,6 +168,7 @@ export function useDirectorySettings({ claude: claudeDir || defaultsRef.current.claude, codex: codexDir || defaultsRef.current.codex, gemini: geminiDir || defaultsRef.current.gemini, + opencode: opencodeDir || defaultsRef.current.opencode, }); } catch (error) { console.error( @@ -183,7 +199,9 @@ export function useDirectorySettings({ ? { claudeConfigDir: sanitized } : key === "codex" ? { codexConfigDir: sanitized } - : { geminiConfigDir: sanitized }, + : key === "gemini" + ? { geminiConfigDir: sanitized } + : { opencodeConfigDir: sanitized }, ); } @@ -205,7 +223,13 @@ export function useDirectorySettings({ const updateDirectory = useCallback( (app: AppId, value?: string) => { updateDirectoryState( - app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini", + app === "claude" + ? "claude" + : app === "codex" + ? "codex" + : app === "gemini" + ? "gemini" + : "opencode", value, ); }, @@ -215,13 +239,21 @@ export function useDirectorySettings({ const browseDirectory = useCallback( async (app: AppId) => { const key: DirectoryKey = - app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini"; + app === "claude" + ? "claude" + : app === "codex" + ? "codex" + : app === "gemini" + ? "gemini" + : "opencode"; const currentValue = key === "claude" ? (settings?.claudeConfigDir ?? resolvedDirs.claude) : key === "codex" ? (settings?.codexConfigDir ?? resolvedDirs.codex) - : (settings?.geminiConfigDir ?? resolvedDirs.gemini); + : key === "gemini" + ? (settings?.geminiConfigDir ?? resolvedDirs.gemini) + : (settings?.opencodeConfigDir ?? resolvedDirs.opencode); try { const picked = await settingsApi.selectConfigDirectory(currentValue); @@ -263,7 +295,13 @@ export function useDirectorySettings({ const resetDirectory = useCallback( async (app: AppId) => { const key: DirectoryKey = - app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini"; + app === "claude" + ? "claude" + : app === "codex" + ? "codex" + : app === "gemini" + ? "gemini" + : "opencode"; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); if (fallback) { @@ -292,7 +330,12 @@ export function useDirectorySettings({ }, [updateDirectoryState]); const resetAllDirectories = useCallback( - (claudeDir?: string, codexDir?: string, geminiDir?: string) => { + ( + claudeDir?: string, + codexDir?: string, + geminiDir?: string, + opencodeDir?: string, + ) => { setAppConfigDir(initialAppConfigDirRef.current); setResolvedDirs({ appConfig: @@ -300,6 +343,7 @@ export function useDirectorySettings({ claude: claudeDir ?? defaultsRef.current.claude, codex: codexDir ?? defaultsRef.current.codex, gemini: geminiDir ?? defaultsRef.current.gemini, + opencode: opencodeDir ?? defaultsRef.current.opencode, }); }, [], diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 897b8e6f5..55d73d0e1 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -109,6 +109,7 @@ export function useSettings(): UseSettingsResult { sanitizeDir(data?.claudeConfigDir), sanitizeDir(data?.codexConfigDir), sanitizeDir(data?.geminiConfigDir), + sanitizeDir(data?.opencodeConfigDir), ); setRequiresRestart(false); }, [ @@ -131,12 +132,16 @@ export function useSettings(): UseSettingsResult { const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir); const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir); const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir); + const sanitizedOpencodeDir = sanitizeDir( + mergedSettings.opencodeConfigDir, + ); const payload: Settings = { ...mergedSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, + opencodeConfigDir: sanitizedOpencodeDir, language: mergedSettings.language, }; @@ -238,16 +243,21 @@ export function useSettings(): UseSettingsResult { const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir); const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir); const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir); + const sanitizedOpencodeDir = sanitizeDir( + mergedSettings.opencodeConfigDir, + ); const previousAppDir = initialAppConfigDir; const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); + const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); const payload: Settings = { ...mergedSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, + opencodeConfigDir: sanitizedOpencodeDir, language: mergedSettings.language, }; @@ -344,11 +354,17 @@ export function useSettings(): UseSettingsResult { console.warn("[useSettings] Failed to refresh tray menu", error); } - // 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置 + // 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; - if (claudeDirChanged || codexDirChanged || geminiDirChanged) { + const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; + if ( + claudeDirChanged || + codexDirChanged || + geminiDirChanged || + opencodeDirChanged + ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { console.warn( diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index a10f57acc..72530920b 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -87,6 +87,8 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), + geminiConfigDir: sanitizeDir(data.geminiConfigDir), + opencodeConfigDir: sanitizeDir(data.opencodeConfigDir), language: normalizedLanguage, }; @@ -143,6 +145,8 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), + geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), + opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir), language: normalizedLanguage, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2e40fbf9d..ac313a05f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -327,9 +327,12 @@ "codexConfigDirDescription": "Override Codex configuration directory.", "geminiConfigDir": "Gemini Configuration Directory", "geminiConfigDirDescription": "Override Gemini configuration directory (.env).", + "opencodeConfigDir": "OpenCode Configuration Directory", + "opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).", "browsePlaceholderClaude": "e.g., /home//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", + "browsePlaceholderOpencode": "e.g., /home//.config/opencode", "browseDirectory": "Browse Directory", "resetDefault": "Reset to default directory (takes effect after saving)", "checkForUpdates": "Check for Updates", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9e367cc51..c2fed4975 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -327,9 +327,12 @@ "codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。", "geminiConfigDir": "Gemini 設定ディレクトリ", "geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。", + "opencodeConfigDir": "OpenCode 設定ディレクトリ", + "opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", + "browsePlaceholderOpencode": "例: /home//.config/opencode", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0634f91aa..44c942c54 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -327,9 +327,12 @@ "codexConfigDirDescription": "覆盖 Codex 配置目录。", "geminiConfigDir": "Gemini 配置目录", "geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。", + "opencodeConfigDir": "OpenCode 配置目录", + "opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", + "browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode", "browseDirectory": "浏览目录", "resetDefault": "恢复默认目录(需保存后生效)", "checkForUpdates": "检查更新", diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx index 6b8204d50..96d745c3d 100644 --- a/tests/hooks/useDirectorySettings.test.tsx +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -64,9 +64,12 @@ describe("useDirectorySettings", () => { ); getAppConfigDirOverrideMock.mockResolvedValue(null); - getConfigDirMock.mockImplementation(async (app: string) => - app === "claude" ? "/remote/claude" : "/remote/codex", - ); + getConfigDirMock.mockImplementation(async (app: string) => { + if (app === "claude") return "/remote/claude"; + if (app === "codex") return "/remote/codex"; + if (app === "gemini") return "/remote/gemini"; + return "/remote/opencode"; + }); selectConfigDirectoryMock.mockReset(); }); @@ -84,7 +87,8 @@ describe("useDirectorySettings", () => { appConfig: "/override/app", claude: "/remote/claude", codex: "/remote/codex", - gemini: "/remote/codex", // Gemini 使用 codex 作为默认 + gemini: "/remote/gemini", + opencode: "/remote/opencode", }); }); @@ -214,10 +218,17 @@ describe("useDirectorySettings", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); act(() => { - result.current.resetAllDirectories("/server/claude", "/server/codex"); + result.current.resetAllDirectories( + "/server/claude", + "/server/codex", + "/server/gemini", + "/server/opencode", + ); }); expect(result.current.resolvedDirs.claude).toBe("/server/claude"); expect(result.current.resolvedDirs.codex).toBe("/server/codex"); + expect(result.current.resolvedDirs.gemini).toBe("/server/gemini"); + expect(result.current.resolvedDirs.opencode).toBe("/server/opencode"); }); }); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 2d5ef42f1..d0a9a5b01 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -381,6 +381,7 @@ describe("useSettings hook", () => { "/server/claude", undefined, undefined, // geminiConfigDir + undefined, // opencodeConfigDir ); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); });