diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index c633782cb..d66f09b9c 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -16,6 +16,7 @@ interface DirectorySettingsProps { codexDir?: string; geminiDir?: string; opencodeDir?: string; + openclawDir?: string; onDirectoryChange: (app: AppId, value?: string) => void; onBrowseDirectory: (app: AppId) => Promise; onResetDirectory: (app: AppId) => Promise; @@ -31,6 +32,7 @@ export function DirectorySettings({ codexDir, geminiDir, opencodeDir, + openclawDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, @@ -130,6 +132,17 @@ export function DirectorySettings({ onBrowse={() => onBrowseDirectory("opencode")} onReset={() => onResetDirectory("opencode")} /> + + onDirectoryChange("openclaw", val)} + onBrowse={() => onBrowseDirectory("openclaw")} + onReset={() => onResetDirectory("openclaw")} + /> ); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index d10e57f30..688e81683 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -316,6 +316,7 @@ export function SettingsPage({ codexDir={settings.codexConfigDir} geminiDir={settings.geminiConfigDir} opencodeDir={settings.opencodeConfigDir} + openclawDir={settings.openclawConfigDir} onDirectoryChange={updateDirectory} onBrowseDirectory={browseDirectory} onResetDirectory={resetDirectory} diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 42e63ef9d..51a28dc4a 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,7 +5,13 @@ 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" | "opencode"; +type DirectoryKey = + | "appConfig" + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw"; export interface ResolvedDirectories { appConfig: string; @@ -13,6 +19,7 @@ export interface ResolvedDirectories { codex: string; gemini: string; opencode: string; + openclaw: string; } const sanitizeDir = (value?: string | null): string | undefined => { @@ -46,7 +53,9 @@ const computeDefaultConfigDir = async ( ? ".codex" : app === "gemini" ? ".gemini" - : ".config/opencode"; + : app === "opencode" + ? ".config/opencode" + : ".openclaw"; return await join(home, folder); } catch (error) { console.error( @@ -78,6 +87,7 @@ export interface UseDirectorySettingsResult { codexDir?: string, geminiDir?: string, opencodeDir?: string, + openclawDir?: string, ) => void; } @@ -105,6 +115,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + openclaw: "", }); const [isLoading, setIsLoading] = useState(true); @@ -114,6 +125,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + openclaw: "", }); const initialAppConfigDirRef = useRef(undefined); @@ -130,22 +142,26 @@ export function useDirectorySettings({ codexDir, geminiDir, opencodeDir, + openclawDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, defaultGeminiDir, defaultOpencodeDir, + defaultOpenclawDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), settingsApi.getConfigDir("codex"), settingsApi.getConfigDir("gemini"), settingsApi.getConfigDir("opencode"), + settingsApi.getConfigDir("openclaw"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), computeDefaultConfigDir("gemini"), computeDefaultConfigDir("opencode"), + computeDefaultConfigDir("openclaw"), ]); if (!active) return; @@ -158,6 +174,7 @@ export function useDirectorySettings({ codex: defaultCodexDir ?? "", gemini: defaultGeminiDir ?? "", opencode: defaultOpencodeDir ?? "", + openclaw: defaultOpenclawDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -169,6 +186,7 @@ export function useDirectorySettings({ codex: codexDir || defaultsRef.current.codex, gemini: geminiDir || defaultsRef.current.gemini, opencode: opencodeDir || defaultsRef.current.opencode, + openclaw: openclawDir || defaultsRef.current.openclaw, }); } catch (error) { console.error( @@ -201,7 +219,9 @@ export function useDirectorySettings({ ? { codexConfigDir: sanitized } : key === "gemini" ? { geminiConfigDir: sanitized } - : { opencodeConfigDir: sanitized }, + : key === "opencode" + ? { opencodeConfigDir: sanitized } + : { openclawConfigDir: sanitized }, ); } @@ -229,7 +249,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode", + : app === "opencode" + ? "opencode" + : "openclaw", value, ); }, @@ -245,7 +267,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "openclaw"; const currentValue = key === "claude" ? (settings?.claudeConfigDir ?? resolvedDirs.claude) @@ -253,7 +277,9 @@ export function useDirectorySettings({ ? (settings?.codexConfigDir ?? resolvedDirs.codex) : key === "gemini" ? (settings?.geminiConfigDir ?? resolvedDirs.gemini) - : (settings?.opencodeConfigDir ?? resolvedDirs.opencode); + : key === "opencode" + ? (settings?.opencodeConfigDir ?? resolvedDirs.opencode) + : (settings?.openclawConfigDir ?? resolvedDirs.openclaw); try { const picked = await settingsApi.selectConfigDirectory(currentValue); @@ -301,7 +327,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "openclaw"; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); if (fallback) { @@ -335,6 +363,7 @@ export function useDirectorySettings({ codexDir?: string, geminiDir?: string, opencodeDir?: string, + openclawDir?: string, ) => { setAppConfigDir(initialAppConfigDirRef.current); setResolvedDirs({ @@ -344,6 +373,7 @@ export function useDirectorySettings({ codex: codexDir ?? defaultsRef.current.codex, gemini: geminiDir ?? defaultsRef.current.gemini, opencode: opencodeDir ?? defaultsRef.current.opencode, + openclaw: openclawDir ?? defaultsRef.current.openclaw, }); }, [], diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index b24e01e43..121f25c72 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -110,6 +110,7 @@ export function useSettings(): UseSettingsResult { sanitizeDir(data?.codexConfigDir), sanitizeDir(data?.geminiConfigDir), sanitizeDir(data?.opencodeConfigDir), + sanitizeDir(data?.openclawConfigDir), ); setRequiresRestart(false); }, [ @@ -135,6 +136,9 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const sanitizedOpenclawDir = sanitizeDir( + mergedSettings.openclawConfigDir, + ); const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; @@ -144,6 +148,7 @@ export function useSettings(): UseSettingsResult { codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, opencodeConfigDir: sanitizedOpencodeDir, + openclawConfigDir: sanitizedOpenclawDir, language: mergedSettings.language, }; @@ -248,11 +253,15 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const sanitizedOpenclawDir = sanitizeDir( + mergedSettings.openclawConfigDir, + ); const previousAppDir = initialAppConfigDir; const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); + const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir); const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; @@ -262,6 +271,7 @@ export function useSettings(): UseSettingsResult { codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, opencodeConfigDir: sanitizedOpencodeDir, + openclawConfigDir: sanitizedOpenclawDir, language: mergedSettings.language, }; @@ -358,16 +368,19 @@ export function useSettings(): UseSettingsResult { console.warn("[useSettings] Failed to refresh tray menu", error); } - // 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; + const openclawDirChanged = + sanitizedOpenclawDir !== previousOpenclawDir; if ( claudeDirChanged || codexDirChanged || geminiDirChanged || - opencodeDirChanged + opencodeDirChanged || + openclawDirChanged ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 0944b91d1..df2323c6a 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -90,6 +90,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), opencodeConfigDir: sanitizeDir(data.opencodeConfigDir), + openclawConfigDir: sanitizeDir(data.openclawConfigDir), language: normalizedLanguage, }; @@ -150,6 +151,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir), + openclawConfigDir: sanitizeDir(serverData.openclawConfigDir), language: normalizedLanguage, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 684f1cc92..cefc88203 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "Override Gemini configuration directory (.env).", "opencodeConfigDir": "OpenCode Configuration Directory", "opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).", + "openclawConfigDir": "OpenClaw Configuration Directory", + "openclawConfigDirDescription": "Override OpenClaw configuration directory (openclaw.json).", "browsePlaceholderClaude": "e.g., /home//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", "browsePlaceholderOpencode": "e.g., /home//.config/opencode", + "browsePlaceholderOpenclaw": "e.g., /home//.openclaw", "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 6d597cd0e..56c3968ae 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。", "opencodeConfigDir": "OpenCode 設定ディレクトリ", "opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。", + "openclawConfigDir": "OpenClaw 設定ディレクトリ", + "openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", "browsePlaceholderOpencode": "例: /home//.config/opencode", + "browsePlaceholderOpenclaw": "例: /home//.openclaw", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 47dabcfbe..9059ef323 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。", "opencodeConfigDir": "OpenCode 配置目录", "opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。", + "openclawConfigDir": "OpenClaw 配置目录", + "openclawConfigDirDescription": "覆盖 OpenClaw 配置目录 (openclaw.json)。", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", "browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode", + "browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw", "browseDirectory": "浏览目录", "resetDefault": "恢复默认目录(需保存后生效)", "checkForUpdates": "检查更新", diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 752251369..aa0dad12e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -21,6 +21,8 @@ export const settingsSchema = z.object({ claudeConfigDir: directorySchema.nullable().optional(), codexConfigDir: directorySchema.nullable().optional(), geminiConfigDir: directorySchema.nullable().optional(), + opencodeConfigDir: directorySchema.nullable().optional(), + openclawConfigDir: directorySchema.nullable().optional(), // 当前供应商 ID(设备级) currentProviderClaude: z.string().optional(), diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx index 96d745c3d..8fa029537 100644 --- a/tests/hooks/useDirectorySettings.test.tsx +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -68,7 +68,8 @@ describe("useDirectorySettings", () => { if (app === "claude") return "/remote/claude"; if (app === "codex") return "/remote/codex"; if (app === "gemini") return "/remote/gemini"; - return "/remote/opencode"; + if (app === "opencode") return "/remote/opencode"; + return "/remote/openclaw"; }); selectConfigDirectoryMock.mockReset(); }); @@ -89,6 +90,7 @@ describe("useDirectorySettings", () => { codex: "/remote/codex", gemini: "/remote/gemini", opencode: "/remote/opencode", + openclaw: "/remote/openclaw", }); }); @@ -211,6 +213,29 @@ describe("useDirectorySettings", () => { expect(result.current.resolvedDirs.appConfig).toBe("/home/mock/.cc-switch"); }); + it("updates openclaw directory when browsing succeeds", async () => { + selectConfigDirectoryMock.mockResolvedValue("/picked/openclaw"); + + const { result } = renderHook(() => + useDirectorySettings({ + settings: createSettings({ openclawConfigDir: undefined }), + onUpdateSettings, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseDirectory("openclaw"); + }); + + expect(selectConfigDirectoryMock).toHaveBeenCalledWith("/remote/openclaw"); + expect(onUpdateSettings).toHaveBeenCalledWith({ + openclawConfigDir: "/picked/openclaw", + }); + expect(result.current.resolvedDirs.openclaw).toBe("/picked/openclaw"); + }); + it("resetAllDirectories applies provided resolved values", async () => { const { result } = renderHook(() => useDirectorySettings({ settings: createSettings(), onUpdateSettings }), @@ -223,6 +248,7 @@ describe("useDirectorySettings", () => { "/server/codex", "/server/gemini", "/server/opencode", + "/server/openclaw", ); }); @@ -230,5 +256,6 @@ describe("useDirectorySettings", () => { expect(result.current.resolvedDirs.codex).toBe("/server/codex"); expect(result.current.resolvedDirs.gemini).toBe("/server/gemini"); expect(result.current.resolvedDirs.opencode).toBe("/server/opencode"); + expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw"); }); }); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index d0a9a5b01..98f11176a 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -72,6 +72,9 @@ const createSettingsFormMock = (overrides: Record = {}) => ({ skipClaudeOnboarding: true, claudeConfigDir: "/claude", codexConfigDir: "/codex", + geminiConfigDir: "/gemini", + opencodeConfigDir: "/opencode", + openclawConfigDir: "/openclaw", language: "zh", }, isLoading: false, @@ -90,6 +93,9 @@ const createDirectorySettingsMock = ( appConfig: "/home/mock/.cc-switch", claude: "/default/claude", codex: "/default/codex", + gemini: "/default/gemini", + opencode: "/default/opencode", + openclaw: "/default/openclaw", }, isLoading: false, initialAppConfigDir: undefined, @@ -132,6 +138,9 @@ describe("useSettings hook", () => { skipClaudeOnboarding: true, claudeConfigDir: "/server/claude", codexConfigDir: "/server/codex", + geminiConfigDir: "/server/gemini", + opencodeConfigDir: "/server/opencode", + openclawConfigDir: "/server/openclaw", language: "zh", }; @@ -218,6 +227,9 @@ describe("useSettings hook", () => { enableClaudePluginIntegration: false, claudeConfigDir: "/server/claude", codexConfigDir: undefined, + geminiConfigDir: "/server/gemini", + opencodeConfigDir: "/server/opencode", + openclawConfigDir: "/server/openclaw", language: "en", }; useSettingsQueryMock.mockReturnValue({ @@ -230,6 +242,7 @@ describe("useSettings hook", () => { ...serverSettings, claudeConfigDir: " /custom/claude ", codexConfigDir: " ", + openclawConfigDir: " /custom/openclaw ", language: "en", enableClaudePluginIntegration: true, // 状态从 false 变为 true }, @@ -253,6 +266,7 @@ describe("useSettings hook", () => { const payload = mutateAsyncMock.mock.calls[0][0] as Settings; expect(payload.claudeConfigDir).toBe("/custom/claude"); expect(payload.codexConfigDir).toBeUndefined(); + expect(payload.openclawConfigDir).toBe("/custom/openclaw"); expect(payload.language).toBe("en"); expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app"); // 状态改变,应该调用 API @@ -380,8 +394,9 @@ describe("useSettings hook", () => { expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith( "/server/claude", undefined, - undefined, // geminiConfigDir - undefined, // opencodeConfigDir + "/server/gemini", + "/server/opencode", + "/server/openclaw", ); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); });