Files
cc-switch/src/components/SettingsModal.tsx
farion1231 e969bdbd73 feat: improve SettingsModal UX with scrolling and save icon
- Add scrollable content area with max height constraint
- Add Save icon to save button for better visual clarity
2025-09-27 11:20:37 +08:00

548 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react";
import {
X,
RefreshCw,
FolderOpen,
Download,
ExternalLink,
Check,
Undo2,
FolderSearch,
Save,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
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;
}
export default function SettingsModal({ onClose }: SettingsModalProps) {
const [settings, setSettings] = useState<Settings>({
showInTray: true,
minimizeToTrayOnClose: true,
claudeConfigDir: undefined,
codexConfigDir: undefined,
});
const [configPath, setConfigPath] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [showUpToDate, setShowUpToDate] = useState(false);
const [resolvedClaudeDir, setResolvedClaudeDir] = useState<string>("");
const [resolvedCodexDir, setResolvedCodexDir] = useState<string>("");
const [isPortable, setIsPortable] = useState(false);
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
useUpdate();
useEffect(() => {
loadSettings();
loadConfigPath();
loadVersion();
loadResolvedDirs();
loadPortableFlag();
}, []);
const loadVersion = async () => {
try {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error("获取版本信息失败:", error);
// 失败时不硬编码版本号,显示为未知
setVersion("未知");
}
};
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;
setSettings({
showInTray,
minimizeToTrayOnClose,
claudeConfigDir:
typeof (loadedSettings as any)?.claudeConfigDir === "string"
? (loadedSettings as any).claudeConfigDir
: undefined,
codexConfigDir:
typeof (loadedSettings as any)?.codexConfigDir === "string"
? (loadedSettings as any).codexConfigDir
: undefined,
});
} catch (error) {
console.error("加载设置失败:", error);
}
};
const loadConfigPath = async () => {
try {
const path = await window.api.getAppConfigPath();
if (path) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", 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("获取配置目录失败:", error);
}
};
const loadPortableFlag = async () => {
try {
const portable = await window.api.isPortable();
setIsPortable(portable);
} catch (error) {
console.error("检测便携模式失败:", error);
}
};
const saveSettings = async () => {
try {
const payload: Settings = {
...settings,
claudeConfigDir:
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
? settings.claudeConfigDir.trim()
: undefined,
codexConfigDir:
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
? settings.codexConfigDir.trim()
: undefined,
};
await window.api.saveSettings(payload);
setSettings(payload);
onClose();
} catch (error) {
console.error("保存设置失败:", error);
}
};
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("更新失败:", 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("检查更新失败:", 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("打开配置文件夹失败:", 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("选择配置目录失败:", 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("获取默认配置目录失败:", error);
return "";
}
};
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;
// 如果未知或为空,回退到 releases 首页
if (!targetVersion || targetVersion === "未知") {
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("打开更新日志失败:", error);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] max-h-[90vh] flex flex-col overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
</h2>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 设置内容 */}
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
{/* 窗口行为设置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
</h3>
<div className="space-y-3">
<label className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-900 dark:text-gray-100">
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
退
</p>
</div>
<input
type="checkbox"
checked={settings.minimizeToTrayOnClose}
onChange={(e) =>
setSettings((prev) => ({
...prev,
minimizeToTrayOnClose: e.target.checked,
}))
}
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
/>
</label>
</div>
</div>
{/* VS Code 自动同步设置已移除 */}
{/* 配置文件位置 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{configPath || "加载中..."}
</span>
</div>
<button
onClick={handleOpenConfigFolder}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="打开文件夹"
>
<FolderOpen
size={18}
className="text-gray-500 dark:text-gray-400"
/>
</button>
</div>
</div>
{/* 配置目录覆盖 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
WSL 使 Claude Code Codex WSL
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Claude Code
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
claudeConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.claude"
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"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("claude")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Codex
</label>
<div className="flex gap-2">
<input
type="text"
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
onChange={(e) =>
setSettings({
...settings,
codexConfigDir: e.target.value,
})
}
placeholder="例如:/home/<你的用户名>/.codex"
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"
/>
<button
type="button"
onClick={() => handleBrowseConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="浏览目录"
>
<FolderSearch size={16} />
</button>
<button
type="button"
onClick={() => handleResetConfigDir("codex")}
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="恢复默认目录(需保存后生效)"
>
<Undo2 size={16} />
</button>
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-start justify-between">
<div>
<div className="text-sm">
<p className="font-medium text-gray-900 dark:text-gray-100">
CC Switch
</p>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{version}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenReleaseNotes}
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
title={
hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
}
>
<span className="inline-flex items-center gap-1">
<ExternalLink size={12} />
</span>
</button>
<button
onClick={handleCheckUpdate}
disabled={isCheckingUpdate || isDownloading}
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
isCheckingUpdate || isDownloading
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
: hasUpdate
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
: showUpToDate
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
}`}
>
{isDownloading ? (
<span className="flex items-center gap-1">
<Download size={12} className="animate-pulse" />
...
</span>
) : isCheckingUpdate ? (
<span className="flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
...
</span>
) : hasUpdate ? (
<span className="flex items-center gap-1">
<Download size={12} />
v{updateInfo?.availableVersion}
</span>
) : showUpToDate ? (
<span className="flex items-center gap-1">
<Check size={12} />
</span>
) : (
"检查更新"
)}
</button>
</div>
</div>
</div>
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
</button>
<button
onClick={saveSettings}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors flex items-center gap-2"
>
<Save size={16} />
</button>
</div>
</div>
</div>
);
}