feat(settings): add Hermes config dir override with data-driven dispatch

Adds a dedicated Hermes row to the directory-override settings so users
can point CC Switch at alternate Hermes config locations (e.g. a second
profile directory for work/personal split). `get_config_dir` on the
Rust side already supports hermes; this just wires up the frontend row.

Wiring it through `useDirectorySettings` revealed a scaling problem:
every supported app required five parallel ternary chains across
`computeDefaultConfigDir`, `updateDirectory`, `browseDirectory`,
`resetDirectory`, and `updateDirectoryState`. Replaces those with two
lookup tables (`APP_DIRECTORY_META`, `DIRECTORY_KEY_TO_SETTINGS_FIELD`)
so adding the next app is two entries, not fifteen edit sites.

Drive-by cleanup from the same touch:
* `resetAllDirectories` takes a `ResolvedAppDirectoryOverrides` object
  instead of five positional optional strings.
* `setResolvedDirs` returns the same reference when the sanitized
  value is unchanged, so no-op edits don't cascade renders.

Also lands all i18n updates for this series (`hermesConfigDir` and
placeholder, Memory section's enable/disable/toggleFailed copy, and
the reworded `schemaMigratedV12` warning) in zh/en/ja together.
This commit is contained in:
Jason
2026-04-20 09:49:13 +08:00
parent 31fb998575
commit b8a3534cb5
9 changed files with 151 additions and 117 deletions
+66 -89
View File
@@ -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"
type AppDirectoryKey =
| "claude"
| "codex"
| "gemini"
| "opencode"
| "openclaw";
| "openclaw"
| "hermes";
type DirectoryKey = "appConfig" | AppDirectoryKey;
export interface ResolvedDirectories {
appConfig: string;
@@ -20,8 +21,34 @@ export interface ResolvedDirectories {
gemini: string;
opencode: string;
openclaw: string;
hermes: string;
}
// Single source of truth for per-app directory metadata.
const APP_DIRECTORY_META: Record<
AppId,
{ key: AppDirectoryKey; defaultFolder: string }
> = {
claude: { key: "claude", defaultFolder: ".claude" },
codex: { key: "codex", defaultFolder: ".codex" },
gemini: { key: "gemini", defaultFolder: ".gemini" },
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
hermes: { key: "hermes", defaultFolder: ".hermes" },
};
const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
AppDirectoryKey,
keyof SettingsFormState
> = {
claude: "claudeConfigDir",
codex: "codexConfigDir",
gemini: "geminiConfigDir",
opencode: "opencodeConfigDir",
openclaw: "openclawConfigDir",
hermes: "hermesConfigDir",
};
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
@@ -46,17 +73,7 @@ const computeDefaultConfigDir = async (
): Promise<string | undefined> => {
try {
const home = await homeDir();
const folder =
app === "claude"
? ".claude"
: app === "codex"
? ".codex"
: app === "gemini"
? ".gemini"
: app === "opencode"
? ".config/opencode"
: ".openclaw";
return await join(home, folder);
return await join(home, APP_DIRECTORY_META[app].defaultFolder);
} catch (error) {
console.error(
"[useDirectorySettings] Failed to resolve default config dir",
@@ -82,15 +99,13 @@ export interface UseDirectorySettingsResult {
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
resetAllDirectories: (
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
openclawDir?: string,
) => void;
resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void;
}
export type ResolvedAppDirectoryOverrides = Partial<
Record<AppDirectoryKey, string | undefined>
>;
/**
* useDirectorySettings - 目录管理
* 负责:
@@ -116,6 +131,7 @@ export function useDirectorySettings({
gemini: "",
opencode: "",
openclaw: "",
hermes: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -126,6 +142,7 @@ export function useDirectorySettings({
gemini: "",
opencode: "",
openclaw: "",
hermes: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -143,12 +160,14 @@ export function useDirectorySettings({
geminiDir,
opencodeDir,
openclawDir,
hermesDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
defaultOpencodeDir,
defaultOpenclawDir,
defaultHermesDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
@@ -156,12 +175,14 @@ export function useDirectorySettings({
settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("opencode"),
settingsApi.getConfigDir("openclaw"),
settingsApi.getConfigDir("hermes"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("opencode"),
computeDefaultConfigDir("openclaw"),
computeDefaultConfigDir("hermes"),
]);
if (!active) return;
@@ -175,6 +196,7 @@ export function useDirectorySettings({
gemini: defaultGeminiDir ?? "",
opencode: defaultOpencodeDir ?? "",
openclaw: defaultOpenclawDir ?? "",
hermes: defaultHermesDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -187,6 +209,7 @@ export function useDirectorySettings({
gemini: geminiDir || defaultsRef.current.gemini,
opencode: opencodeDir || defaultsRef.current.opencode,
openclaw: openclawDir || defaultsRef.current.openclaw,
hermes: hermesDir || defaultsRef.current.hermes,
});
} catch (error) {
console.error(
@@ -212,23 +235,18 @@ export function useDirectorySettings({
if (key === "appConfig") {
setAppConfigDir(sanitized);
} else {
onUpdateSettings(
key === "claude"
? { claudeConfigDir: sanitized }
: key === "codex"
? { codexConfigDir: sanitized }
: key === "gemini"
? { geminiConfigDir: sanitized }
: key === "opencode"
? { opencodeConfigDir: sanitized }
: { openclawConfigDir: sanitized },
);
onUpdateSettings({
[DIRECTORY_KEY_TO_SETTINGS_FIELD[key]]: sanitized,
});
}
setResolvedDirs((prev) => ({
...prev,
[key]: sanitized ?? defaultsRef.current[key],
}));
setResolvedDirs((prev) => {
const next = sanitized ?? defaultsRef.current[key];
// Same-ref early-return: unchanged value shouldn't cascade renders
// through the settings tree.
if (prev[key] === next) return prev;
return { ...prev, [key]: next };
});
},
[onUpdateSettings],
);
@@ -242,44 +260,17 @@ export function useDirectorySettings({
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
updateDirectoryState(
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: app === "opencode"
? "opencode"
: "openclaw",
value,
);
updateDirectoryState(APP_DIRECTORY_META[app].key, value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: app === "opencode"
? "opencode"
: "openclaw";
const key = APP_DIRECTORY_META[app].key;
const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key];
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: key === "codex"
? (settings?.codexConfigDir ?? resolvedDirs.codex)
: key === "gemini"
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
: key === "opencode"
? (settings?.opencodeConfigDir ?? resolvedDirs.opencode)
: (settings?.openclawConfigDir ?? resolvedDirs.openclaw);
(settings?.[settingsField] as string | undefined) ?? resolvedDirs[key];
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -320,16 +311,7 @@ export function useDirectorySettings({
const resetDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: app === "opencode"
? "opencode"
: "openclaw";
const key = APP_DIRECTORY_META[app].key;
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
@@ -358,22 +340,17 @@ export function useDirectorySettings({
}, [updateDirectoryState]);
const resetAllDirectories = useCallback(
(
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
openclawDir?: string,
) => {
(overrides?: ResolvedAppDirectoryOverrides) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini,
opencode: opencodeDir ?? defaultsRef.current.opencode,
openclaw: openclawDir ?? defaultsRef.current.openclaw,
claude: overrides?.claude ?? defaultsRef.current.claude,
codex: overrides?.codex ?? defaultsRef.current.codex,
gemini: overrides?.gemini ?? defaultsRef.current.gemini,
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
});
},
[],
+8 -7
View File
@@ -107,13 +107,14 @@ export function useSettings(): UseSettingsResult {
const resetSettings = useCallback(() => {
resetForm(data ?? null);
syncLanguage(initialLanguage);
resetAllDirectories(
sanitizeDir(data?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir),
sanitizeDir(data?.geminiConfigDir),
sanitizeDir(data?.opencodeConfigDir),
sanitizeDir(data?.openclawConfigDir),
);
resetAllDirectories({
claude: sanitizeDir(data?.claudeConfigDir),
codex: sanitizeDir(data?.codexConfigDir),
gemini: sanitizeDir(data?.geminiConfigDir),
opencode: sanitizeDir(data?.opencodeConfigDir),
openclaw: sanitizeDir(data?.openclawConfigDir),
hermes: sanitizeDir(data?.hermesConfigDir),
});
setRequiresRestart(false);
}, [
data,