From 2b45af118f76f5f252e83f1ee438027a46b437c6 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 16 Oct 2025 11:40:02 +0800 Subject: [PATCH] feat: complete stage 3 settings refactor --- README_i18n.md | 2 +- docs/REFACTORING_CHECKLIST.md | 2 +- docs/REFACTORING_MASTER_PLAN.md | 14 +- src/App.tsx | 14 +- src/components/ConfirmDialog.tsx | 95 +- src/components/SettingsModal.tsx | 1046 ----------------- src/components/settings/AboutSection.tsx | 236 ++++ src/components/settings/ConfigPathDisplay.tsx | 41 + src/components/settings/DirectorySettings.tsx | 147 +++ .../settings/ImportExportSection.tsx | 189 +++ src/components/settings/LanguageSettings.tsx | 64 + src/components/settings/SettingsDialog.tsx | 286 +++++ src/components/settings/WindowSettings.tsx | 75 ++ src/hooks/useImportExport.ts | 187 +++ src/hooks/useSettings.ts | 498 ++++++++ src/lib/api/settings.ts | 51 + src/types.ts | 2 +- 17 files changed, 1828 insertions(+), 1121 deletions(-) delete mode 100644 src/components/SettingsModal.tsx create mode 100644 src/components/settings/AboutSection.tsx create mode 100644 src/components/settings/ConfigPathDisplay.tsx create mode 100644 src/components/settings/DirectorySettings.tsx create mode 100644 src/components/settings/ImportExportSection.tsx create mode 100644 src/components/settings/LanguageSettings.tsx create mode 100644 src/components/settings/SettingsDialog.tsx create mode 100644 src/components/settings/WindowSettings.tsx create mode 100644 src/hooks/useImportExport.ts create mode 100644 src/hooks/useSettings.ts diff --git a/README_i18n.md b/README_i18n.md index 1caf9b9..eef75a7 100644 --- a/README_i18n.md +++ b/README_i18n.md @@ -67,7 +67,7 @@ src/ - ✅ EditProviderModal.tsx - 编辑供应商弹窗 - ✅ ProviderList.tsx - 供应商列表 - ✅ LanguageSwitcher.tsx - 语言切换器 -- 🔄 SettingsModal.tsx - 设置弹窗(部分完成) +- ✅ settings/SettingsDialog.tsx - 设置对话框 ## 注意事项 diff --git a/docs/REFACTORING_CHECKLIST.md b/docs/REFACTORING_CHECKLIST.md index dbdafce..3000120 100644 --- a/docs/REFACTORING_CHECKLIST.md +++ b/docs/REFACTORING_CHECKLIST.md @@ -415,7 +415,7 @@ pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate | App.tsx | 412 | ~100 | -76% | | tauri-api.ts | 712 | ~50 | -93% | | ProviderForm.tsx | 271 | ~150 | -45% | -| SettingsModal.tsx | 643 | ~400 (拆分) | -38% | +| settings 模块 | 1046 | ~470 (拆分) | -55% | | **总计** | 2038 | ~700 | **-66%** | --- diff --git a/docs/REFACTORING_MASTER_PLAN.md b/docs/REFACTORING_MASTER_PLAN.md index 65a9d8d..db62b34 100644 --- a/docs/REFACTORING_MASTER_PLAN.md +++ b/docs/REFACTORING_MASTER_PLAN.md @@ -872,7 +872,7 @@ export function useDragSort( | **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 | | **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 | | **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 | -| **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 | +| **阶段 3** | 重构设置和辅助(✅ 已完成) | 2-3 天 | SettingsDialog、通知系统完成 | | **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 | | **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 | | **总计** | - | **11-16 天** | v4.0.0 发布 | @@ -1488,15 +1488,15 @@ export const useTheme = () => { ### 阶段 3: 设置和辅助功能 (2-3天) -**目标**: 重构 SettingsModal 和通知系统 +**目标**: 重构设置模块和通知系统 #### 任务清单 -- [ ] 拆分 SettingsDialog (7个组件) -- [ ] 创建 `useSettings` Hook -- [ ] 创建 `useImportExport` Hook -- [ ] 替换通知系统为 Sonner -- [ ] 重构 ConfirmDialog +- [x] 拆分 SettingsDialog (7个组件) +- [x] 创建 `useSettings` Hook +- [x] 创建 `useImportExport` Hook +- [x] 替换通知系统为 Sonner +- [x] 重构 ConfirmDialog #### 详细步骤 diff --git a/src/App.tsx b/src/App.tsx index 5613217..f2c13b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,7 @@ import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import SettingsModal from "@/components/SettingsModal"; +import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; import McpPanel from "@/components/mcp/McpPanel"; @@ -325,13 +325,11 @@ function App() { onCancel={() => setConfirmDelete(null)} /> - {isSettingsOpen && ( - setIsSettingsOpen(false)} - onImportSuccess={handleImportSuccess} - onNotify={handleNotify} - /> - )} + {isMcpOpen && ( void; } -export const ConfirmDialog: React.FC = ({ +export function ConfirmDialog({ isOpen, title, message, @@ -21,63 +28,37 @@ export const ConfirmDialog: React.FC = ({ cancelText, onConfirm, onCancel, -}) => { +}: ConfirmDialogProps) { const { t } = useTranslation(); - if (!isOpen) return null; - return ( -
- {/* Backdrop */} -
- - {/* Dialog */} -
- {/* Header */} -
-
-
- -
-

- {title} -

-
- -
- - {/* Content */} -
-

+

{ + if (!open) { + onCancel(); + } + }} + > + + + + + {title} + + {message} -

-
- - {/* Actions */} -
- - + -
-
-
+ + + + ); -}; +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx deleted file mode 100644 index 2a1f1df..0000000 --- a/src/components/SettingsModal.tsx +++ /dev/null @@ -1,1046 +0,0 @@ -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { - X, - RefreshCw, - FolderOpen, - Download, - ExternalLink, - Check, - Undo2, - FolderSearch, - Save, -} from "lucide-react"; -import { getVersion } from "@tauri-apps/api/app"; -import { ImportProgressModal } from "./ImportProgressModal"; -import { homeDir, join } from "@tauri-apps/api/path"; -import "../lib/tauri-api"; -import { relaunchApp } from "../lib/updater"; -import { useUpdate } from "../contexts/UpdateContext"; -import type { Settings } from "../types"; -import type { AppType } from "../lib/tauri-api"; -import { isLinux } from "../lib/platform"; - -interface SettingsModalProps { - onClose: () => void; - onImportSuccess?: () => void | Promise; - onNotify?: ( - message: string, - type: "success" | "error", - duration?: number, - ) => void; -} - -export default function SettingsModal({ - onClose, - onImportSuccess, - onNotify, -}: SettingsModalProps) { - const { t, i18n } = useTranslation(); - - const normalizeLanguage = (lang?: string | null): "zh" | "en" => - lang === "en" ? "en" : "zh"; - - const readPersistedLanguage = (): "zh" | "en" => { - if (typeof window !== "undefined") { - const stored = window.localStorage.getItem("language"); - if (stored === "en" || stored === "zh") { - return stored; - } - } - return normalizeLanguage(i18n.language); - }; - - const persistedLanguage = readPersistedLanguage(); - - const [settings, setSettings] = useState({ - showInTray: true, - minimizeToTrayOnClose: true, - enableClaudePluginIntegration: false, - claudeConfigDir: undefined, - codexConfigDir: undefined, - language: persistedLanguage, - }); - // appConfigDir 现在从 Store 独立管理 - const [appConfigDir, setAppConfigDir] = useState( - undefined, - ); - const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">( - persistedLanguage, - ); - const [configPath, setConfigPath] = useState(""); - const [version, setVersion] = useState(""); - const [isCheckingUpdate, setIsCheckingUpdate] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - const [showUpToDate, setShowUpToDate] = useState(false); - const [resolvedAppConfigDir, setResolvedAppConfigDir] = useState(""); - const [resolvedClaudeDir, setResolvedClaudeDir] = useState(""); - const [resolvedCodexDir, setResolvedCodexDir] = useState(""); - const [isPortable, setIsPortable] = useState(false); - const [initialAppConfigDir, setInitialAppConfigDir] = useState< - string | undefined - >(undefined); - const [showRestartDialog, setShowRestartDialog] = useState(false); - const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } = - useUpdate(); - - // 导入/导出相关状态 - const [isImporting, setIsImporting] = useState(false); - const [importStatus, setImportStatus] = useState< - "idle" | "importing" | "success" | "error" - >("idle"); - const [importError, setImportError] = useState(""); - const [importBackupId, setImportBackupId] = useState(""); - const [selectedImportFile, setSelectedImportFile] = useState(""); - - useEffect(() => { - loadSettings(); - loadAppConfigDirFromStore(); // 从 Store 加载 appConfigDir - loadConfigPath(); - loadVersion(); - loadResolvedDirs(); - loadPortableFlag(); - }, []); - - const loadVersion = async () => { - try { - const appVersion = await getVersion(); - setVersion(appVersion); - } catch (error) { - console.error(t("console.getVersionFailed"), error); - // 失败时不硬编码版本号,显示为未知 - setVersion(t("common.unknown")); - } - }; - - // 从 Tauri Store 加载 appConfigDir - const loadAppConfigDirFromStore = async () => { - try { - const storeValue = await (window as any).api.getAppConfigDirOverride(); - if (storeValue) { - setAppConfigDir(storeValue); - setInitialAppConfigDir(storeValue); - setResolvedAppConfigDir(storeValue); - } else { - // 使用默认值 - const defaultDir = await computeDefaultAppConfigDir(); - setResolvedAppConfigDir(defaultDir); - } - } catch (error) { - console.error("从 Store 加载 appConfigDir 失败:", error); - } - }; - - const loadSettings = async () => { - try { - const loadedSettings = await window.api.getSettings(); - const showInTray = - (loadedSettings as any)?.showInTray ?? - (loadedSettings as any)?.showInDock ?? - true; - const minimizeToTrayOnClose = - (loadedSettings as any)?.minimizeToTrayOnClose ?? - (loadedSettings as any)?.minimize_to_tray_on_close ?? - true; - const storedLanguage = normalizeLanguage( - typeof (loadedSettings as any)?.language === "string" - ? (loadedSettings as any).language - : persistedLanguage, - ); - - setSettings({ - showInTray, - minimizeToTrayOnClose, - enableClaudePluginIntegration: - typeof (loadedSettings as any)?.enableClaudePluginIntegration === - "boolean" - ? (loadedSettings as any).enableClaudePluginIntegration - : false, - claudeConfigDir: - typeof (loadedSettings as any)?.claudeConfigDir === "string" - ? (loadedSettings as any).claudeConfigDir - : undefined, - codexConfigDir: - typeof (loadedSettings as any)?.codexConfigDir === "string" - ? (loadedSettings as any).codexConfigDir - : undefined, - language: storedLanguage, - }); - setInitialLanguage(storedLanguage); - if (i18n.language !== storedLanguage) { - void i18n.changeLanguage(storedLanguage); - } - } catch (error) { - console.error(t("console.loadSettingsFailed"), error); - } - }; - - const loadConfigPath = async () => { - try { - const path = await window.api.getAppConfigPath(); - if (path) { - setConfigPath(path); - } - } catch (error) { - console.error(t("console.getConfigPathFailed"), error); - } - }; - - const loadResolvedDirs = async () => { - try { - const [claudeDir, codexDir] = await Promise.all([ - window.api.getConfigDir("claude"), - window.api.getConfigDir("codex"), - ]); - setResolvedClaudeDir(claudeDir || ""); - setResolvedCodexDir(codexDir || ""); - } catch (error) { - console.error(t("console.getConfigDirFailed"), error); - } - }; - - const loadPortableFlag = async () => { - try { - const portable = await window.api.isPortable(); - setIsPortable(portable); - } catch (error) { - console.error(t("console.detectPortableFailed"), error); - } - }; - - const saveSettings = async () => { - try { - const selectedLanguage = settings.language === "en" ? "en" : "zh"; - const payload: Settings = { - ...settings, - claudeConfigDir: - settings.claudeConfigDir && settings.claudeConfigDir.trim() !== "" - ? settings.claudeConfigDir.trim() - : undefined, - codexConfigDir: - settings.codexConfigDir && settings.codexConfigDir.trim() !== "" - ? settings.codexConfigDir.trim() - : undefined, - language: selectedLanguage, - }; - - // 保存 settings.json (不包含 appConfigDir) - await window.api.saveSettings(payload); - - // 单独保存 appConfigDir 到 Store - const normalizedAppConfigDir = - appConfigDir && appConfigDir.trim() !== "" - ? appConfigDir.trim() - : null; - await (window as any).api.setAppConfigDirOverride(normalizedAppConfigDir); - - // 立即生效:根据开关无条件写入/移除 ~/.claude/config.json - try { - if (payload.enableClaudePluginIntegration) { - await window.api.applyClaudePluginConfig({ official: false }); - } else { - await window.api.applyClaudePluginConfig({ official: true }); - } - } catch (e) { - console.warn("[Settings] Apply Claude plugin config on save failed", e); - } - - // 检测 appConfigDir 是否真正发生变化 - const appConfigDirChanged = - (normalizedAppConfigDir || undefined) !== - (initialAppConfigDir || undefined); - - setSettings(payload); - setInitialAppConfigDir(normalizedAppConfigDir ?? undefined); - try { - window.localStorage.setItem("language", selectedLanguage); - } catch (error) { - console.warn("[Settings] Failed to persist language preference", error); - } - setInitialLanguage(selectedLanguage); - if (i18n.language !== selectedLanguage) { - void i18n.changeLanguage(selectedLanguage); - } - - // 如果修改了 appConfigDir,需要提示用户重启应用程序 - if (appConfigDirChanged) { - setShowRestartDialog(true); - } else { - onClose(); - } - } catch (error) { - console.error(t("console.saveSettingsFailed"), error); - } - }; - - const handleRestartNow = async () => { - // 开发模式下不真正重启,只提示 - if (import.meta.env.DEV) { - onNotify?.( - t("settings.devModeRestartHint"), - "success", - 5000, - ); - setShowRestartDialog(false); - onClose(); - return; - } - - // 生产模式下真正重启应用 - try { - await window.api.restartApp(); - } catch (e) { - console.warn("[Settings] Restart app failed", e); - // 如果重启失败,仍然关闭设置窗口 - setShowRestartDialog(false); - onClose(); - } - }; - - const handleRestartLater = () => { - setShowRestartDialog(false); - onClose(); - }; - - const handleLanguageChange = (lang: "zh" | "en") => { - setSettings((prev) => ({ ...prev, language: lang })); - if (i18n.language !== lang) { - void i18n.changeLanguage(lang); - } - }; - - const handleCancel = () => { - if (settings.language !== initialLanguage) { - setSettings((prev) => ({ ...prev, language: initialLanguage })); - if (i18n.language !== initialLanguage) { - void i18n.changeLanguage(initialLanguage); - } - } - onClose(); - }; - - const handleCheckUpdate = async () => { - if (hasUpdate && updateHandle) { - if (isPortable) { - await window.api.checkForUpdates(); - return; - } - // 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查 - setIsDownloading(true); - try { - resetDismiss(); - await updateHandle.downloadAndInstall(); - await relaunchApp(); - } catch (error) { - console.error(t("console.updateFailed"), error); - // 更新失败时回退到打开 Releases 页面 - await window.api.checkForUpdates(); - } finally { - setIsDownloading(false); - } - } else { - // 尚未检测到更新:先检查 - setIsCheckingUpdate(true); - setShowUpToDate(false); - try { - const hasNewUpdate = await checkUpdate(); - // 检查完成后,如果没有更新,显示"已是最新" - if (!hasNewUpdate) { - setShowUpToDate(true); - // 3秒后恢复按钮文字 - setTimeout(() => { - setShowUpToDate(false); - }, 3000); - } - } catch (error) { - console.error(t("console.checkUpdateFailed"), error); - // 在开发模式下,模拟已是最新版本的响应 - if (import.meta.env.DEV) { - setShowUpToDate(true); - setTimeout(() => { - setShowUpToDate(false); - }, 3000); - } else { - // 生产环境下如果更新插件不可用,回退到打开 Releases 页面 - await window.api.checkForUpdates(); - } - } finally { - setIsCheckingUpdate(false); - } - } - }; - - const handleOpenConfigFolder = async () => { - try { - await window.api.openAppConfigFolder(); - } catch (error) { - console.error(t("console.openConfigFolderFailed"), error); - } - }; - - const handleBrowseAppConfigDir = async () => { - try { - const currentResolved = appConfigDir ?? resolvedAppConfigDir; - const selected = await window.api.selectConfigDirectory(currentResolved); - - if (!selected) { - return; - } - - const sanitized = selected.trim(); - - if (sanitized === "") { - return; - } - - setAppConfigDir(sanitized); - setResolvedAppConfigDir(sanitized); - } catch (error) { - console.error(t("console.selectConfigDirFailed"), error); - } - }; - - const handleBrowseConfigDir = async (app: AppType) => { - try { - const currentResolved = - app === "claude" - ? (settings.claudeConfigDir ?? resolvedClaudeDir) - : (settings.codexConfigDir ?? resolvedCodexDir); - - const selected = await window.api.selectConfigDirectory(currentResolved); - - if (!selected) { - return; - } - - const sanitized = selected.trim(); - - if (sanitized === "") { - return; - } - - if (app === "claude") { - setSettings((prev) => ({ ...prev, claudeConfigDir: sanitized })); - setResolvedClaudeDir(sanitized); - } else { - setSettings((prev) => ({ ...prev, codexConfigDir: sanitized })); - setResolvedCodexDir(sanitized); - } - } catch (error) { - console.error(t("console.selectConfigDirFailed"), error); - } - }; - - const computeDefaultConfigDir = async (app: AppType) => { - try { - const home = await homeDir(); - const folder = app === "claude" ? ".claude" : ".codex"; - return await join(home, folder); - } catch (error) { - console.error(t("console.getDefaultConfigDirFailed"), error); - return ""; - } - }; - - const computeDefaultAppConfigDir = async () => { - try { - const home = await homeDir(); - return await join(home, ".cc-switch"); - } catch (error) { - console.error(t("console.getDefaultConfigDirFailed"), error); - return ""; - } - }; - - const handleResetAppConfigDir = async () => { - setAppConfigDir(undefined); - const defaultDir = await computeDefaultAppConfigDir(); - if (defaultDir) { - setResolvedAppConfigDir(defaultDir); - } - }; - - const handleResetConfigDir = async (app: AppType) => { - setSettings((prev) => ({ - ...prev, - ...(app === "claude" - ? { claudeConfigDir: undefined } - : { codexConfigDir: undefined }), - })); - - const defaultDir = await computeDefaultConfigDir(app); - if (!defaultDir) { - return; - } - - if (app === "claude") { - setResolvedClaudeDir(defaultDir); - } else { - setResolvedCodexDir(defaultDir); - } - }; - - const handleOpenReleaseNotes = async () => { - try { - const targetVersion = updateInfo?.availableVersion || version; - const unknownLabel = t("common.unknown"); - // 如果未知或为空,回退到 releases 首页 - if (!targetVersion || targetVersion === unknownLabel) { - await window.api.openExternal( - "https://github.com/farion1231/cc-switch/releases", - ); - return; - } - const tag = targetVersion.startsWith("v") - ? targetVersion - : `v${targetVersion}`; - await window.api.openExternal( - `https://github.com/farion1231/cc-switch/releases/tag/${tag}`, - ); - } catch (error) { - console.error(t("console.openReleaseNotesFailed"), error); - } - }; - - // 导出配置处理函数 - const handleExportConfig = async () => { - try { - const defaultName = `cc-switch-config-${new Date().toISOString().split("T")[0]}.json`; - const filePath = await window.api.saveFileDialog(defaultName); - - if (!filePath) { - onNotify?.( - `${t("settings.exportFailed")}: ${t("settings.selectFileFailed")}`, - "error", - 4000, - ); - return; - } - - const result = await window.api.exportConfigToFile(filePath); - - if (result.success) { - onNotify?.( - `${t("settings.configExported")}\n${result.filePath}`, - "success", - 4000, - ); - } - } catch (error) { - console.error(t("settings.exportFailedError"), error); - onNotify?.( - `${t("settings.exportFailed")}: ${String(error)}`, - "error", - 5000, - ); - } - }; - - // 选择要导入的文件 - const handleSelectImportFile = async () => { - try { - const filePath = await window.api.openFileDialog(); - if (filePath) { - setSelectedImportFile(filePath); - setImportStatus("idle"); // 重置状态 - setImportError(""); - } - } catch (error) { - console.error(t("settings.selectFileFailed") + ":", error); - onNotify?.( - `${t("settings.selectFileFailed")}: ${String(error)}`, - "error", - 5000, - ); - } - }; - - // 执行导入 - const handleExecuteImport = async () => { - if (!selectedImportFile || isImporting) return; - - setIsImporting(true); - setImportStatus("importing"); - - try { - const result = await window.api.importConfigFromFile(selectedImportFile); - - if (result.success) { - setImportBackupId(result.backupId || ""); - setImportStatus("success"); - // ImportProgressModal 会在2秒后触发数据刷新回调 - } else { - setImportError(result.message || t("settings.configCorrupted")); - setImportStatus("error"); - } - } catch (error) { - setImportError(String(error)); - setImportStatus("error"); - } finally { - setIsImporting(false); - } - }; - - return ( -
{ - if (e.target === e.currentTarget) handleCancel(); - }} - > -
-
- {/* 标题栏 */} -
-

- {t("settings.title")} -

- -
- - {/* 设置内容 */} -
- {/* 语言设置 */} -
-

- {t("settings.language")} -

-
- - -
-
- - {/* 窗口行为设置 */} -
-

- {t("settings.windowBehavior")} -

-
- - {/* Claude 插件联动开关 */} - -
-
- - {/* 配置文件位置 */} -
-

- {t("settings.configFileLocation")} -

-
-
- - {configPath || t("common.loading")} - -
- -
-
- - {/* 配置目录覆盖 */} -
-

- {t("settings.configDirectoryOverride")} -

-

- {t("settings.configDirectoryDescription")} -

-
-
- -

- {t("settings.appConfigDirDescription")} -

-
- setAppConfigDir(e.target.value)} - placeholder={t("settings.browsePlaceholderApp")} - className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40" - /> - - -
-
- -
- -
- - setSettings({ - ...settings, - claudeConfigDir: e.target.value, - }) - } - placeholder={t("settings.browsePlaceholderClaude")} - className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40" - /> - - -
-
- -
- -
- - setSettings({ - ...settings, - codexConfigDir: e.target.value, - }) - } - placeholder={t("settings.browsePlaceholderCodex")} - className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40" - /> - - -
-
-
-
- - {/* 导入导出 */} -
-

- {t("settings.importExport")} -

-
-
- {/* 导出按钮 */} - - - {/* 导入区域 */} -
-
- - -
- - {/* 显示选择的文件 */} - {selectedImportFile && ( -
- {selectedImportFile.split("/").pop() || - selectedImportFile.split("\\").pop() || - selectedImportFile} -
- )} -
-
-
-
- - {/* 关于 */} -
-

- {t("common.about")} -

-
-
-
-
-

- CC Switch -

-

- {t("common.version")} {version} -

-
-
-
- - -
-
-
-
-
- - {/* 底部按钮 */} -
- - -
-
- - {/* Import Progress Modal */} - {importStatus !== "idle" && ( - { - setImportStatus("idle"); - setImportError(""); - setSelectedImportFile(""); - }} - onSuccess={() => { - if (onImportSuccess) { - void onImportSuccess(); - } - void window.api - .updateTrayMenu() - .catch((error) => - console.error( - "[SettingsModal] Failed to refresh tray menu", - error, - ), - ); - }} - /> - )} - - {/* Restart Confirmation Dialog */} - {showRestartDialog && ( -
-
-
-

- {t("settings.restartRequired")} -

-

- {t("settings.restartRequiredMessage")} -

-
- - -
-
-
- )} -
- ); -} \ No newline at end of file diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx new file mode 100644 index 0000000..4a0ddcd --- /dev/null +++ b/src/components/settings/AboutSection.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from "react"; +import { Download, ExternalLink, Info, Loader2, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { getVersion } from "@tauri-apps/api/app"; +import { settingsApi } from "@/lib/api"; +import { useUpdate } from "@/contexts/UpdateContext"; +import { relaunchApp } from "@/lib/updater"; + +interface AboutSectionProps { + isPortable: boolean; +} + +export function AboutSection({ isPortable }: AboutSectionProps) { + const { t } = useTranslation(); + const [version, setVersion] = useState(null); + const [isLoadingVersion, setIsLoadingVersion] = useState(true); + const [isDownloading, setIsDownloading] = useState(false); + + const { + hasUpdate, + updateInfo, + updateHandle, + checkUpdate, + resetDismiss, + isChecking, + } = useUpdate(); + + useEffect(() => { + let active = true; + const load = async () => { + try { + const loaded = await getVersion(); + if (active) { + setVersion(loaded); + } + } catch (error) { + console.error("[AboutSection] Failed to get version", error); + if (active) { + setVersion(null); + } + } finally { + if (active) { + setIsLoadingVersion(false); + } + } + }; + + void load(); + return () => { + active = false; + }; + }, []); + + const handleOpenReleaseNotes = useCallback(async () => { + try { + const targetVersion = updateInfo?.availableVersion ?? version ?? ""; + const displayVersion = targetVersion.startsWith("v") + ? targetVersion + : targetVersion + ? `v${targetVersion}` + : ""; + + if (!displayVersion) { + await settingsApi.openExternal( + "https://github.com/farion1231/cc-switch/releases", + ); + return; + } + + await settingsApi.openExternal( + `https://github.com/farion1231/cc-switch/releases/tag/${displayVersion}`, + ); + } catch (error) { + console.error("[AboutSection] Failed to open release notes", error); + toast.error( + t("settings.openReleaseNotesFailed", { + defaultValue: "打开更新日志失败", + }), + ); + } + }, [t, updateInfo?.availableVersion, version]); + + const handleCheckUpdate = useCallback(async () => { + if (hasUpdate && updateHandle) { + if (isPortable) { + try { + await settingsApi.checkUpdates(); + } catch (error) { + console.error("[AboutSection] Portable update failed", error); + } + return; + } + + setIsDownloading(true); + try { + resetDismiss(); + await updateHandle.downloadAndInstall(); + await relaunchApp(); + } catch (error) { + console.error("[AboutSection] Update failed", error); + toast.error( + t("settings.updateFailed", { + defaultValue: "更新安装失败,已尝试打开下载页面。", + }), + ); + try { + await settingsApi.checkUpdates(); + } catch (fallbackError) { + console.error("[AboutSection] Failed to open fallback updater", fallbackError); + } + } finally { + setIsDownloading(false); + } + return; + } + + try { + const available = await checkUpdate(); + if (!available) { + toast.success( + t("settings.upToDate", { defaultValue: "已是最新版本" }), + ); + } + } catch (error) { + console.error("[AboutSection] Check update failed", error); + toast.error( + t("settings.checkUpdateFailed", { + defaultValue: "检查更新失败,请稍后重试。", + }), + ); + } + }, [ + checkUpdate, + hasUpdate, + isPortable, + resetDismiss, + t, + updateHandle, + ]); + + const displayVersion = + version ?? t("common.unknown", { defaultValue: "未知" }); + + return ( +
+
+

{t("common.about")}

+

+ {t("settings.aboutHint", { + defaultValue: "查看版本信息与更新状态。", + })} +

+
+ +
+
+
+

CC Switch

+

+ {t("common.version")}{" "} + {isLoadingVersion ? ( + + ) : ( + `v${displayVersion}` + )} +

+ {isPortable ? ( +

+ + {t("settings.portableMode", { + defaultValue: "当前为便携版,更新需手动下载。", + })} +

+ ) : null} +
+ +
+ + +
+
+ + {hasUpdate && updateInfo ? ( +
+

+ {t("settings.updateAvailable", { + defaultValue: "检测到新版本:{{version}}", + version: updateInfo.availableVersion, + })} +

+ {updateInfo.notes ? ( +

{updateInfo.notes}

+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/settings/ConfigPathDisplay.tsx b/src/components/settings/ConfigPathDisplay.tsx new file mode 100644 index 0000000..5b13747 --- /dev/null +++ b/src/components/settings/ConfigPathDisplay.tsx @@ -0,0 +1,41 @@ +import { FolderOpen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; + +interface ConfigPathDisplayProps { + path: string; + onOpen: () => Promise | void; +} + +export function ConfigPathDisplay({ path, onOpen }: ConfigPathDisplayProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t("settings.configFileLocation")} +

+

+ {t("settings.configFileLocationHint", { + defaultValue: "显示当前使用的配置文件路径。", + })} +

+
+
+ + {path || t("common.loading")} + + +
+
+ ); +} diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx new file mode 100644 index 0000000..b1143f1 --- /dev/null +++ b/src/components/settings/DirectorySettings.tsx @@ -0,0 +1,147 @@ +import { useMemo } from "react"; +import { FolderSearch, Undo2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import type { AppType } from "@/lib/api"; +import type { ResolvedDirectories } from "@/hooks/useSettings"; + +interface DirectorySettingsProps { + appConfigDir?: string; + resolvedDirs: ResolvedDirectories; + onAppConfigChange: (value?: string) => void; + onBrowseAppConfig: () => Promise; + onResetAppConfig: () => Promise; + claudeDir?: string; + codexDir?: string; + onDirectoryChange: (app: AppType, value?: string) => void; + onBrowseDirectory: (app: AppType) => Promise; + onResetDirectory: (app: AppType) => Promise; +} + +export function DirectorySettings({ + appConfigDir, + resolvedDirs, + onAppConfigChange, + onBrowseAppConfig, + onResetAppConfig, + claudeDir, + codexDir, + onDirectoryChange, + onBrowseDirectory, + onResetDirectory, +}: DirectorySettingsProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t("settings.configDirectoryOverride")} +

+

+ {t("settings.configDirectoryDescription")} +

+
+ + + + onDirectoryChange("claude", val)} + onBrowse={() => onBrowseDirectory("claude")} + onReset={() => onResetDirectory("claude")} + /> + + onDirectoryChange("codex", val)} + onBrowse={() => onBrowseDirectory("codex")} + onReset={() => onResetDirectory("codex")} + /> +
+ ); +} + +interface DirectoryInputProps { + label: string; + description?: string; + value?: string; + resolvedValue: string; + placeholder?: string; + onChange: (value?: string) => void; + onBrowse: () => Promise; + onReset: () => Promise; +} + +function DirectoryInput({ + label, + description, + value, + resolvedValue, + placeholder, + onChange, + onBrowse, + onReset, +}: DirectoryInputProps) { + const { t } = useTranslation(); + const displayValue = useMemo(() => value ?? resolvedValue ?? "", [value, resolvedValue]); + + return ( +
+
+

{label}

+ {description ? ( +

{description}

+ ) : null} +
+
+ onChange(event.target.value)} + /> + + +
+
+ ); +} diff --git a/src/components/settings/ImportExportSection.tsx b/src/components/settings/ImportExportSection.tsx new file mode 100644 index 0000000..6a8add9 --- /dev/null +++ b/src/components/settings/ImportExportSection.tsx @@ -0,0 +1,189 @@ +import { useMemo } from "react"; +import { + AlertCircle, + CheckCircle2, + FolderOpen, + Loader2, + Save, + XCircle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import type { ImportStatus } from "@/hooks/useImportExport"; + +interface ImportExportSectionProps { + status: ImportStatus; + selectedFile: string; + errorMessage: string | null; + backupId: string | null; + isImporting: boolean; + onSelectFile: () => Promise; + onImport: () => Promise; + onExport: () => Promise; + onClear: () => void; +} + +export function ImportExportSection({ + status, + selectedFile, + errorMessage, + backupId, + isImporting, + onSelectFile, + onImport, + onExport, + onClear, +}: ImportExportSectionProps) { + const { t } = useTranslation(); + + const selectedFileName = useMemo(() => { + if (!selectedFile) return ""; + const segments = selectedFile.split(/[\\/]/); + return segments[segments.length - 1] || selectedFile; + }, [selectedFile]); + + return ( +
+
+

{t("settings.importExport")}

+

+ {t("settings.importExportHint", { + defaultValue: "导入导出 cc-switch 配置,便于备份或迁移。", + })} +

+
+ +
+ + +
+
+ + + {selectedFile ? ( + + ) : null} +
+ + {selectedFile ? ( +

+ {selectedFileName} +

+ ) : ( +

+ {t("settings.noFileSelected", { + defaultValue: "尚未选择配置文件。", + })} +

+ )} +
+ + +
+
+ ); +} + +interface ImportStatusMessageProps { + status: ImportStatus; + errorMessage: string | null; + backupId: string | null; +} + +function ImportStatusMessage({ + status, + errorMessage, + backupId, +}: ImportStatusMessageProps) { + const { t } = useTranslation(); + + if (status === "idle") { + return null; + } + + const baseClass = + "flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed"; + + if (status === "importing") { + return ( +
+ +
+

{t("settings.importing")}

+

+ {t("common.loading", { defaultValue: "正在处理..." })} +

+
+
+ ); + } + + if (status === "success") { + return ( +
+ +
+

{t("settings.importSuccess")}

+ {backupId ? ( +

+ {t("settings.backupId", { defaultValue: "备份 ID" })}: {backupId} +

+ ) : null} +

{t("settings.autoReload", { defaultValue: "即将刷新列表。" })}

+
+
+ ); + } + + const message = + errorMessage || + t("settings.importFailed", { defaultValue: "导入失败,请重试。" }); + + return ( +
+ +
+

+ {t("settings.importFailed", { defaultValue: "导入失败" })} +

+

{message}

+
+
+ ); +} diff --git a/src/components/settings/LanguageSettings.tsx b/src/components/settings/LanguageSettings.tsx new file mode 100644 index 0000000..2b493b8 --- /dev/null +++ b/src/components/settings/LanguageSettings.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +interface LanguageSettingsProps { + value: "zh" | "en"; + onChange: (value: "zh" | "en") => void; +} + +export function LanguageSettings({ value, onChange }: LanguageSettingsProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t("settings.language")}

+

+ {t("settings.languageHint", { + defaultValue: "切换后立即预览界面语言,保存后永久生效。", + })} +

+
+
+ onChange("zh")} + > + {t("settings.languageOptionChinese")} + + onChange("en")} + > + {t("settings.languageOptionEnglish")} + +
+
+ ); +} + +interface LanguageButtonProps { + active: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function LanguageButton({ active, onClick, children }: LanguageButtonProps) { + return ( + + ); +} diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..ee3a20d --- /dev/null +++ b/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { settingsApi } from "@/lib/api"; +import { LanguageSettings } from "@/components/settings/LanguageSettings"; +import { WindowSettings } from "@/components/settings/WindowSettings"; +import { ConfigPathDisplay } from "@/components/settings/ConfigPathDisplay"; +import { DirectorySettings } from "@/components/settings/DirectorySettings"; +import { ImportExportSection } from "@/components/settings/ImportExportSection"; +import { AboutSection } from "@/components/settings/AboutSection"; +import { useSettings } from "@/hooks/useSettings"; +import { useImportExport } from "@/hooks/useImportExport"; +import { useTranslation } from "react-i18next"; + +interface SettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImportSuccess?: () => void | Promise; +} + +export function SettingsDialog({ + open, + onOpenChange, + onImportSuccess, +}: SettingsDialogProps) { + const { t } = useTranslation(); + const { + settings, + isLoading, + isSaving, + isPortable, + configPath, + appConfigDir, + resolvedDirs, + updateSettings, + updateDirectory, + updateAppConfigDir, + browseDirectory, + browseAppConfigDir, + resetDirectory, + resetAppConfigDir, + openConfigFolder, + saveSettings, + resetSettings, + requiresRestart, + acknowledgeRestart, + } = useSettings(); + + const { + selectedFile, + status: importStatus, + errorMessage, + backupId, + isImporting, + selectImportFile, + importConfig, + exportConfig, + clearSelection, + resetStatus, + } = useImportExport({ onImportSuccess }); + + const [activeTab, setActiveTab] = useState("general"); + const [showRestartPrompt, setShowRestartPrompt] = useState(false); + + useEffect(() => { + if (open) { + setActiveTab("general"); + resetStatus(); + } + }, [open, resetStatus]); + + useEffect(() => { + if (requiresRestart) { + setShowRestartPrompt(true); + } + }, [requiresRestart]); + + const closeDialog = useCallback(() => { + resetSettings(); + acknowledgeRestart(); + clearSelection(); + resetStatus(); + onOpenChange(false); + }, [acknowledgeRestart, clearSelection, onOpenChange, resetSettings, resetStatus]); + + const handleDialogChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + closeDialog(); + } else { + onOpenChange(true); + } + }, + [closeDialog, onOpenChange], + ); + + const handleCancel = useCallback(() => { + closeDialog(); + }, [closeDialog]); + + const handleSave = useCallback(async () => { + try { + const result = await saveSettings(); + if (!result) return; + if (result.requiresRestart) { + setShowRestartPrompt(true); + return; + } + closeDialog(); + } catch (error) { + console.error("[SettingsDialog] Failed to save settings", error); + } + }, [closeDialog, saveSettings]); + + const handleRestartLater = useCallback(() => { + setShowRestartPrompt(false); + closeDialog(); + }, [closeDialog]); + + const handleRestartNow = useCallback(async () => { + setShowRestartPrompt(false); + if (import.meta.env.DEV) { + toast.success( + t("settings.devModeRestartHint", { + defaultValue: "开发模式下不支持自动重启,请手动重新启动应用。", + }), + ); + closeDialog(); + return; + } + + try { + await settingsApi.restart(); + } catch (error) { + console.error("[SettingsDialog] Failed to restart app", error); + toast.error( + t("settings.restartFailed", { + defaultValue: "应用重启失败,请手动关闭后重新打开。", + }), + ); + } finally { + closeDialog(); + } + }, [closeDialog, t]); + + const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]); + + return ( + + + + {t("settings.title")} + + + {isBusy ? ( +
+ +
+ ) : ( +
+ + + + {t("settings.tabGeneral", { defaultValue: "通用" })} + + + {t("settings.tabAdvanced", { defaultValue: "高级" })} + + + {t("common.about")} + + + +
+ + {settings ? ( + <> + updateSettings({ language: lang })} + /> + + + + ) : null} + + + + {settings ? ( + <> + + + + ) : null} + + + + + +
+
+
+ )} + + + + + +
+ + {showRestartPrompt ? ( +
+
+
+
+

+ {t("settings.restartRequired")} +

+

+ {t("settings.restartRequiredMessage", { + defaultValue: "配置目录已变更,需要重启应用生效。", + })} +

+
+
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/src/components/settings/WindowSettings.tsx b/src/components/settings/WindowSettings.tsx new file mode 100644 index 0000000..21b099f --- /dev/null +++ b/src/components/settings/WindowSettings.tsx @@ -0,0 +1,75 @@ +import { Switch } from "@/components/ui/switch"; +import { useTranslation } from "react-i18next"; +import type { SettingsFormState } from "@/hooks/useSettings"; + +interface WindowSettingsProps { + settings: SettingsFormState; + onChange: (updates: Partial) => void; +} + +export function WindowSettings({ settings, onChange }: WindowSettingsProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t("settings.windowBehavior")} +

+

+ {t("settings.windowBehaviorHint", { + defaultValue: "配置窗口最小化与 Claude 插件联动策略。", + })} +

+
+ + + onChange({ minimizeToTrayOnClose: value }) + } + /> + + + onChange({ enableClaudePluginIntegration: value }) + } + /> +
+ ); +} + +interface ToggleRowProps { + title: string; + description?: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; +} + +function ToggleRow({ + title, + description, + checked, + onCheckedChange, +}: ToggleRowProps) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ +
+ ); +} diff --git a/src/hooks/useImportExport.ts b/src/hooks/useImportExport.ts new file mode 100644 index 0000000..cacdb40 --- /dev/null +++ b/src/hooks/useImportExport.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { settingsApi } from "@/lib/api"; + +export type ImportStatus = "idle" | "importing" | "success" | "error"; + +export interface UseImportExportOptions { + onImportSuccess?: () => void | Promise; +} + +export interface UseImportExportResult { + selectedFile: string; + status: ImportStatus; + errorMessage: string | null; + backupId: string | null; + isImporting: boolean; + selectImportFile: () => Promise; + clearSelection: () => void; + importConfig: () => Promise; + exportConfig: () => Promise; + resetStatus: () => void; +} + +export function useImportExport( + options: UseImportExportOptions = {}, +): UseImportExportResult { + const { t } = useTranslation(); + const { onImportSuccess } = options; + + const [selectedFile, setSelectedFile] = useState(""); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [backupId, setBackupId] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const successTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (successTimerRef.current) { + window.clearTimeout(successTimerRef.current); + } + }; + }, []); + + const clearSelection = useCallback(() => { + setSelectedFile(""); + setStatus("idle"); + setErrorMessage(null); + setBackupId(null); + }, []); + + const selectImportFile = useCallback(async () => { + try { + const filePath = await settingsApi.openFileDialog(); + if (filePath) { + setSelectedFile(filePath); + setStatus("idle"); + setErrorMessage(null); + } + } catch (error) { + console.error("[useImportExport] Failed to open file dialog", error); + toast.error( + t("settings.selectFileFailed", { + defaultValue: "选择文件失败", + }), + ); + } + }, [t]); + + const importConfig = useCallback(async () => { + if (!selectedFile) { + toast.error( + t("settings.selectFileFailed", { + defaultValue: "请选择有效的配置文件", + }), + ); + return; + } + + if (isImporting) return; + + setIsImporting(true); + setStatus("importing"); + setErrorMessage(null); + + try { + const result = await settingsApi.importConfigFromFile(selectedFile); + if (result.success) { + setBackupId(result.backupId ?? null); + setStatus("success"); + toast.success( + t("settings.importSuccess", { + defaultValue: "配置导入成功", + }), + ); + + successTimerRef.current = window.setTimeout(() => { + void onImportSuccess?.(); + }, 1500); + } else { + setStatus("error"); + const message = + result.message || + t("settings.configCorrupted", { + defaultValue: "配置文件已损坏或格式不正确", + }); + setErrorMessage(message); + toast.error(message); + } + } catch (error) { + console.error("[useImportExport] Failed to import config", error); + setStatus("error"); + const message = + error instanceof Error ? error.message : String(error ?? ""); + setErrorMessage(message); + toast.error( + t("settings.importFailedError", { + defaultValue: "导入配置失败: {{message}}", + message, + }), + ); + } finally { + setIsImporting(false); + } + }, [isImporting, onImportSuccess, selectedFile, t]); + + const exportConfig = useCallback(async () => { + try { + const defaultName = `cc-switch-config-${ + new Date().toISOString().split("T")[0] + }.json`; + const destination = await settingsApi.saveFileDialog(defaultName); + if (!destination) { + toast.error( + t("settings.selectFileFailed", { + defaultValue: "选择保存位置失败", + }), + ); + return; + } + + const result = await settingsApi.exportConfigToFile(destination); + if (result.success) { + const displayPath = result.filePath ?? destination; + toast.success( + t("settings.configExported", { + defaultValue: "配置已导出", + }) + `\n${displayPath}`, + ); + } else { + toast.error( + t("settings.exportFailed", { + defaultValue: "导出配置失败", + }) + (result.message ? `: ${result.message}` : ""), + ); + } + } catch (error) { + console.error("[useImportExport] Failed to export config", error); + toast.error( + t("settings.exportFailedError", { + defaultValue: "导出配置失败: {{message}}", + message: error instanceof Error ? error.message : String(error ?? ""), + }), + ); + } + }, [t]); + + const resetStatus = useCallback(() => { + setStatus("idle"); + setErrorMessage(null); + setBackupId(null); + }, []); + + return { + selectedFile, + status, + errorMessage, + backupId, + isImporting, + selectImportFile, + clearSelection, + importConfig, + exportConfig, + resetStatus, + }; +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..0d6f3eb --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,498 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { homeDir, join } from "@tauri-apps/api/path"; +import { settingsApi, type AppType } from "@/lib/api"; +import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; +import type { Settings } from "@/types"; + +type Language = "zh" | "en"; + +export type SettingsFormState = Omit & { + language: Language; +}; + +type DirectoryKey = "appConfig" | "claude" | "codex"; + +export interface ResolvedDirectories { + appConfig: string; + claude: string; + codex: string; +} + +interface SaveResult { + requiresRestart: boolean; +} + +export interface UseSettingsResult { + settings: SettingsFormState | null; + isLoading: boolean; + isSaving: boolean; + isPortable: boolean; + configPath: string; + appConfigDir?: string; + resolvedDirs: ResolvedDirectories; + requiresRestart: boolean; + updateSettings: (updates: Partial) => void; + updateDirectory: (app: AppType, value?: string) => void; + updateAppConfigDir: (value?: string) => void; + browseDirectory: (app: AppType) => Promise; + browseAppConfigDir: () => Promise; + resetDirectory: (app: AppType) => Promise; + resetAppConfigDir: () => Promise; + openConfigFolder: () => Promise; + saveSettings: () => Promise; + resetSettings: () => void; + acknowledgeRestart: () => void; +} + +const normalizeLanguage = (lang?: string | null): Language => { + if (!lang) return "zh"; + return lang === "en" ? "en" : "zh"; +}; + +const sanitizeDir = (value?: string | null): string | undefined => { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const computeDefaultAppConfigDir = async (): Promise => { + try { + const home = await homeDir(); + return await join(home, ".cc-switch"); + } catch (error) { + console.error("[useSettings] Failed to resolve default app config dir", error); + return undefined; + } +}; + +const computeDefaultConfigDir = async (app: AppType): Promise => { + try { + const home = await homeDir(); + const folder = app === "claude" ? ".claude" : ".codex"; + return await join(home, folder); + } catch (error) { + console.error("[useSettings] Failed to resolve default config dir", error); + return undefined; + } +}; + +export function useSettings(): UseSettingsResult { + const { t, i18n } = useTranslation(); + const { data, isLoading } = useSettingsQuery(); + const saveMutation = useSaveSettingsMutation(); + + const [settingsState, setSettingsState] = useState(null); + const [appConfigDir, setAppConfigDir] = useState(undefined); + const [configPath, setConfigPath] = useState(""); + const [isPortable, setIsPortable] = useState(false); + const [requiresRestart, setRequiresRestart] = useState(false); + const [resolvedDirs, setResolvedDirs] = useState({ + appConfig: "", + claude: "", + codex: "", + }); + const [isAuxiliaryLoading, setIsAuxiliaryLoading] = useState(true); + + const defaultsRef = useRef({ + appConfig: "", + claude: "", + codex: "", + }); + const initialLanguageRef = useRef("zh"); + const initialAppConfigDirRef = useRef(undefined); + + const readPersistedLanguage = useCallback((): Language => { + if (typeof window !== "undefined") { + const stored = window.localStorage.getItem("language"); + if (stored === "en" || stored === "zh") { + return stored; + } + } + return normalizeLanguage(i18n.language); + }, [i18n.language]); + + const syncLanguage = useCallback( + (lang: Language) => { + const current = normalizeLanguage(i18n.language); + if (current !== lang) { + void i18n.changeLanguage(lang); + } + }, + [i18n], + ); + + // 初始化设置数据 + useEffect(() => { + if (!data) return; + + const normalizedLanguage = normalizeLanguage( + data.language ?? readPersistedLanguage(), + ); + + const normalized: SettingsFormState = { + ...data, + showInTray: data.showInTray ?? true, + minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true, + enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false, + claudeConfigDir: sanitizeDir(data.claudeConfigDir), + codexConfigDir: sanitizeDir(data.codexConfigDir), + language: normalizedLanguage, + }; + + setSettingsState(normalized); + initialLanguageRef.current = normalizedLanguage; + syncLanguage(normalizedLanguage); + }, [data, readPersistedLanguage, syncLanguage]); + + // 加载辅助信息(目录、配置路径、便携模式) + useEffect(() => { + let active = true; + setIsAuxiliaryLoading(true); + + const load = async () => { + try { + const [ + overrideRaw, + appConfigPath, + claudeDir, + codexDir, + portable, + defaultAppConfig, + defaultClaudeDir, + defaultCodexDir, + ] = await Promise.all([ + settingsApi.getAppConfigDirOverride(), + settingsApi.getAppConfigPath(), + settingsApi.getConfigDir("claude"), + settingsApi.getConfigDir("codex"), + settingsApi.isPortable(), + computeDefaultAppConfigDir(), + computeDefaultConfigDir("claude"), + computeDefaultConfigDir("codex"), + ]); + + if (!active) return; + + const normalizedOverride = sanitizeDir(overrideRaw ?? undefined); + + defaultsRef.current = { + appConfig: defaultAppConfig ?? "", + claude: defaultClaudeDir ?? "", + codex: defaultCodexDir ?? "", + }; + + setAppConfigDir(normalizedOverride); + initialAppConfigDirRef.current = normalizedOverride; + + setResolvedDirs({ + appConfig: normalizedOverride ?? defaultsRef.current.appConfig, + claude: claudeDir || defaultsRef.current.claude, + codex: codexDir || defaultsRef.current.codex, + }); + + setConfigPath(appConfigPath || ""); + setIsPortable(portable); + } catch (error) { + console.error("[useSettings] Failed to load directory info", error); + } finally { + if (active) { + setIsAuxiliaryLoading(false); + } + } + }; + + void load(); + return () => { + active = false; + }; + }, []); + + const updateSettings = useCallback( + (updates: Partial) => { + setSettingsState((prev) => { + const base = + prev ?? + ({ + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + language: readPersistedLanguage(), + } as SettingsFormState); + + const next: SettingsFormState = { + ...base, + ...updates, + }; + + if (updates.language) { + const normalized = normalizeLanguage(updates.language); + next.language = normalized; + syncLanguage(normalized); + } + + return next; + }); + }, + [readPersistedLanguage, syncLanguage], + ); + + const updateDirectoryState = useCallback( + (key: DirectoryKey, value?: string) => { + const sanitized = sanitizeDir(value); + if (key === "appConfig") { + setAppConfigDir(sanitized); + } else { + setSettingsState((prev) => { + if (!prev) return prev; + if (key === "claude") { + return { + ...prev, + claudeConfigDir: sanitized, + }; + } + return { + ...prev, + codexConfigDir: sanitized, + }; + }); + } + + setResolvedDirs((prev) => ({ + ...prev, + [key]: sanitized ?? defaultsRef.current[key], + })); + }, + [], + ); + + const updateAppConfigDir = useCallback( + (value?: string) => { + updateDirectoryState("appConfig", value); + }, + [updateDirectoryState], + ); + + const updateDirectory = useCallback( + (app: AppType, value?: string) => { + updateDirectoryState(app === "claude" ? "claude" : "codex", value); + }, + [updateDirectoryState], + ); + + const browseDirectory = useCallback( + async (app: AppType) => { + const key: DirectoryKey = app === "claude" ? "claude" : "codex"; + const currentValue = + key === "claude" + ? settingsState?.claudeConfigDir ?? resolvedDirs.claude + : settingsState?.codexConfigDir ?? resolvedDirs.codex; + + try { + const picked = await settingsApi.selectConfigDirectory(currentValue); + const sanitized = sanitizeDir(picked ?? undefined); + if (!sanitized) return; + updateDirectoryState(key, sanitized); + } catch (error) { + console.error("[useSettings] Failed to pick directory", error); + toast.error( + t("settings.selectFileFailed", { + defaultValue: "选择目录失败", + }), + ); + } + }, + [settingsState, resolvedDirs, t, updateDirectoryState], + ); + + const browseAppConfigDir = useCallback(async () => { + const currentValue = appConfigDir ?? resolvedDirs.appConfig; + try { + const picked = await settingsApi.selectConfigDirectory(currentValue); + const sanitized = sanitizeDir(picked ?? undefined); + if (!sanitized) return; + updateDirectoryState("appConfig", sanitized); + } catch (error) { + console.error("[useSettings] Failed to pick app config directory", error); + toast.error( + t("settings.selectFileFailed", { + defaultValue: "选择目录失败", + }), + ); + } + }, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]); + + const resetDirectory = useCallback( + async (app: AppType) => { + const key: DirectoryKey = app === "claude" ? "claude" : "codex"; + if (!defaultsRef.current[key]) { + const fallback = await computeDefaultConfigDir(app); + if (fallback) { + defaultsRef.current = { + ...defaultsRef.current, + [key]: fallback, + }; + } + } + updateDirectoryState(key, undefined); + }, + [updateDirectoryState], + ); + + const resetAppConfigDir = useCallback(async () => { + if (!defaultsRef.current.appConfig) { + const fallback = await computeDefaultAppConfigDir(); + if (fallback) { + defaultsRef.current = { + ...defaultsRef.current, + appConfig: fallback, + }; + } + } + updateDirectoryState("appConfig", undefined); + }, [updateDirectoryState]); + + const openConfigFolder = useCallback(async () => { + try { + await settingsApi.openAppConfigFolder(); + } catch (error) { + console.error("[useSettings] Failed to open config folder", error); + toast.error( + t("settings.openFolderFailed", { + defaultValue: "打开目录失败", + }), + ); + } + }, [t]); + + const resetSettings = useCallback(() => { + if (!data) return; + + const normalizedLanguage = normalizeLanguage( + data.language ?? readPersistedLanguage(), + ); + + const normalized: SettingsFormState = { + ...data, + showInTray: data.showInTray ?? true, + minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true, + enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false, + claudeConfigDir: sanitizeDir(data.claudeConfigDir), + codexConfigDir: sanitizeDir(data.codexConfigDir), + language: normalizedLanguage, + }; + + setSettingsState(normalized); + syncLanguage(initialLanguageRef.current); + setAppConfigDir(initialAppConfigDirRef.current); + setResolvedDirs({ + appConfig: initialAppConfigDirRef.current ?? defaultsRef.current.appConfig, + claude: normalized.claudeConfigDir ?? defaultsRef.current.claude, + codex: normalized.codexConfigDir ?? defaultsRef.current.codex, + }); + setRequiresRestart(false); + }, [data, readPersistedLanguage, syncLanguage]); + + const acknowledgeRestart = useCallback(() => { + setRequiresRestart(false); + }, []); + + const saveSettings = useCallback(async (): Promise => { + if (!settingsState) return null; + try { + const sanitizedAppDir = sanitizeDir(appConfigDir); + const sanitizedClaudeDir = sanitizeDir(settingsState.claudeConfigDir); + const sanitizedCodexDir = sanitizeDir(settingsState.codexConfigDir); + const previousAppDir = initialAppConfigDirRef.current; + const payload: Settings = { + ...settingsState, + claudeConfigDir: sanitizedClaudeDir, + codexConfigDir: sanitizedCodexDir, + language: settingsState.language, + }; + + await saveMutation.mutateAsync(payload); + + await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null); + + try { + if (payload.enableClaudePluginIntegration) { + await settingsApi.applyClaudePluginConfig({ official: false }); + } else { + await settingsApi.applyClaudePluginConfig({ official: true }); + } + } catch (error) { + console.warn("[useSettings] Failed to sync Claude plugin config", error); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + } + + try { + if (typeof window !== "undefined") { + window.localStorage.setItem("language", payload.language as Language); + } + } catch (error) { + console.warn("[useSettings] Failed to persist language preference", error); + } + + initialLanguageRef.current = payload.language as Language; + setSettingsState((prev) => + prev + ? { + ...prev, + claudeConfigDir: sanitizedClaudeDir, + codexConfigDir: sanitizedCodexDir, + language: payload.language as Language, + } + : prev, + ); + + setResolvedDirs({ + appConfig: sanitizedAppDir ?? defaultsRef.current.appConfig, + claude: sanitizedClaudeDir ?? defaultsRef.current.claude, + codex: sanitizedCodexDir ?? defaultsRef.current.codex, + }); + setAppConfigDir(sanitizedAppDir); + + const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined); + initialAppConfigDirRef.current = sanitizedAppDir; + setRequiresRestart(appDirChanged); + + return { requiresRestart: appDirChanged }; + } catch (error) { + console.error("[useSettings] Failed to save settings", error); + throw error; + } + }, [appConfigDir, saveMutation, settingsState, t]); + + const isBusy = useMemo( + () => isLoading || isAuxiliaryLoading, + [isLoading, isAuxiliaryLoading], + ); + + return { + settings: settingsState, + isLoading: isBusy, + isSaving: saveMutation.isPending, + isPortable, + configPath, + appConfigDir, + resolvedDirs, + requiresRestart, + updateSettings, + updateDirectory, + updateAppConfigDir, + browseDirectory, + browseAppConfigDir, + resetDirectory, + resetAppConfigDir, + openConfigFolder, + saveSettings, + resetSettings, + acknowledgeRestart, + }; +} diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 1dc01fd..3dec1f7 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -2,6 +2,13 @@ import { invoke } from "@tauri-apps/api/core"; import type { Settings } from "@/types"; import type { AppType } from "./types"; +export interface ConfigTransferResult { + success: boolean; + message: string; + filePath?: string; + backupId?: string; +} + export const settingsApi = { async get(): Promise { return await invoke("get_settings"); @@ -49,4 +56,48 @@ export const settingsApi = { async openAppConfigFolder(): Promise { await invoke("open_app_config_folder"); }, + + async getAppConfigDirOverride(): Promise { + return await invoke("get_app_config_dir_override"); + }, + + async setAppConfigDirOverride(path: string | null): Promise { + return await invoke("set_app_config_dir_override", { path }); + }, + + async applyClaudePluginConfig(options: { + official: boolean; + }): Promise { + const { official } = options; + return await invoke("apply_claude_plugin_config", { official }); + }, + + async saveFileDialog(defaultName: string): Promise { + return await invoke("save_file_dialog", { + default_name: defaultName, + defaultName, + }); + }, + + async openFileDialog(): Promise { + return await invoke("open_file_dialog"); + }, + + async exportConfigToFile(filePath: string): Promise { + return await invoke("export_config_to_file", { + file_path: filePath, + filePath, + }); + }, + + async importConfigFromFile(filePath: string): Promise { + return await invoke("import_config_from_file", { + file_path: filePath, + filePath, + }); + }, + + async openExternal(url: string): Promise { + await invoke("open_external", { url }); + }, }; diff --git a/src/types.ts b/src/types.ts index ae4c20b..6a55e11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,7 +65,7 @@ export interface ProviderMeta { usage_script?: UsageScript; } -// 应用设置类型(用于 SettingsModal 与 Tauri API) +// 应用设置类型(用于设置对话框与 Tauri API) export interface Settings { // 是否在系统托盘(macOS 菜单栏)显示图标 showInTray: boolean;