feat: more granular local environment checks (#870)

* feat: more granular local environment checks

* refactor: improve PR #870 with i18n, shadcn Select, and testable helpers

- Extract is_valid_shell, is_valid_shell_flag, default_flag_for_shell
  to module-level #[cfg(windows)] functions for testability
- Add unit tests for extracted helper functions
- Replace native <select> with shadcn/ui Select components
- Extract env badge ternary to ENV_BADGE_CONFIG Record lookup
- Add i18n keys for env badges and WSL selectors (zh/en/ja)
- Unify initial useEffect load path with loadAllToolVersions()

* fix: prevent useEffect re-firing on wslShellByTool changes

The useEffect that loads initial tool versions depended on
loadAllToolVersions, which in turn depended on wslShellByTool.
This caused a full re-fetch of all 4 tools every time the user
changed a WSL shell or flag, racing with the single-tool refresh.

Fix: use empty deps [] since this is a mount-only effect. The
refresh button and shell/flag handlers cover subsequent updates.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Kelvin Chiu
2026-02-23 11:26:23 +08:00
committed by GitHub
parent 4c88174cb0
commit d11df17b5d
6 changed files with 471 additions and 58 deletions
+192 -11
View File
@@ -11,6 +11,13 @@ import {
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getVersion } from "@tauri-apps/api/app";
@@ -30,8 +37,48 @@ interface ToolVersion {
version: string | null;
latest_version: string | null;
error: string | null;
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
wsl_distro: string | null;
}
const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const;
type ToolName = (typeof TOOL_NAMES)[number];
type WslShellPreference = {
wslShell?: string | null;
wslShellFlag?: string | null;
};
const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const;
// UI-friendly order: login shell first.
const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const;
const ENV_BADGE_CONFIG: Record<
string,
{ labelKey: string; className: string }
> = {
wsl: {
labelKey: "settings.envBadge.wsl",
className:
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
},
windows: {
labelKey: "settings.envBadge.windows",
className:
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
macos: {
labelKey: "settings.envBadge.macos",
className:
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20",
},
linux: {
labelKey: "settings.envBadge.linux",
className:
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
},
};
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
curl -fsSL https://claude.ai/install.sh | bash
# Codex
@@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
isChecking,
} = useUpdate();
const loadToolVersions = useCallback(async () => {
const [wslShellByTool, setWslShellByTool] = useState<
Record<string, WslShellPreference>
>({});
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
const refreshToolVersions = useCallback(
async (
toolNames: ToolName[],
wslOverrides?: Record<string, WslShellPreference>,
) => {
if (toolNames.length === 0) return;
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
setLoadingTools((prev) => {
const next = { ...prev };
for (const name of toolNames) next[name] = true;
return next;
});
try {
const updated = await settingsApi.getToolVersions(
toolNames,
wslOverrides,
);
setToolVersions((prev) => {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
});
} catch (error) {
console.error("[AboutSection] Failed to refresh tools", error);
} finally {
setLoadingTools((prev) => {
const next = { ...prev };
for (const name of toolNames) next[name] = false;
return next;
});
}
},
[],
);
const loadAllToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
const tools = await settingsApi.getToolVersions();
setToolVersions(tools);
// Respect current UI overrides (shell / flag) when doing a full refresh.
const versions = await settingsApi.getToolVersions(
[...TOOL_NAMES],
wslShellByTool,
);
setToolVersions(versions);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, []);
}, [wslShellByTool]);
const handleToolShellChange = async (toolName: ToolName, value: string) => {
const wslShell = value === "auto" ? null : value;
const nextPref: WslShellPreference = {
...(wslShellByTool[toolName] ?? {}),
wslShell,
};
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
await refreshToolVersions([toolName], { [toolName]: nextPref });
};
const handleToolShellFlagChange = async (
toolName: ToolName,
value: string,
) => {
const wslShellFlag = value === "auto" ? null : value;
const nextPref: WslShellPreference = {
...(wslShellByTool[toolName] ?? {}),
wslShellFlag,
};
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
await refreshToolVersions([toolName], { [toolName]: nextPref });
};
useEffect(() => {
let active = true;
const load = async () => {
try {
const [appVersion, tools] = await Promise.all([
const [appVersion] = await Promise.all([
getVersion(),
settingsApi.getToolVersions(),
loadAllToolVersions(),
]);
if (active) {
setVersion(appVersion);
setToolVersions(tools);
}
} catch (error) {
console.error("[AboutSection] Failed to load info", error);
@@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
} finally {
if (active) {
setIsLoadingVersion(false);
setIsLoadingTools(false);
}
}
};
@@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
return () => {
active = false;
};
// Mount-only: loadAllToolVersions is intentionally excluded to avoid
// re-fetching all tools whenever wslShellByTool changes. Single-tool
// refreshes are handled by refreshToolVersions in the shell/flag handlers.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
@@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={loadToolVersions}
onClick={() => loadAllToolVersions()}
disabled={isLoadingTools}
>
<RefreshCw
@@ -317,8 +441,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
{TOOL_NAMES.map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
// Special case for OpenCode (capital C), others use capitalize
const displayName =
@@ -340,8 +465,64 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{displayName}</span>
{/* Environment Badge */}
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
>
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
</span>
)}
{/* WSL Shell Selector */}
{tool?.env_type === "wsl" && (
<Select
value={wslShellByTool[toolName]?.wslShell || "auto"}
onValueChange={(v) =>
handleToolShellChange(toolName, v)
}
disabled={isLoadingTools || loadingTools[toolName]}
>
<SelectTrigger className="h-6 w-[70px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t("common.auto")}
</SelectItem>
{WSL_SHELL_OPTIONS.map((shell) => (
<SelectItem key={shell} value={shell}>
{shell}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* WSL Shell Flag Selector */}
{tool?.env_type === "wsl" && (
<Select
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
onValueChange={(v) =>
handleToolShellFlagChange(toolName, v)
}
disabled={isLoadingTools || loadingTools[toolName]}
>
<SelectTrigger className="h-6 w-[70px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t("common.auto")}
</SelectItem>
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
<SelectItem key={flag} value={flag}>
{flag}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isLoadingTools ? (
{isLoadingTools || loadingTools[toolName] ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : tool?.version ? (
<div className="flex items-center gap-1.5">
+10 -1
View File
@@ -37,7 +37,8 @@
"search": "Search",
"reset": "Reset",
"actions": "Actions",
"deleting": "Deleting..."
"deleting": "Deleting...",
"auto": "Auto"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -452,6 +453,14 @@
"oneClickInstall": "One-click Install",
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode",
"localEnvCheck": "Local environment check",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
"macos": "macOS",
"linux": "Linux"
},
"wslShell": "Shell",
"wslShellFlag": "Flag",
"installCommandsCopied": "Install commands copied",
"installCommandsCopyFailed": "Copy failed, please copy manually.",
"importFailedError": "Import config failed: {{message}}",
+10 -1
View File
@@ -37,7 +37,8 @@
"search": "検索",
"reset": "リセット",
"actions": "操作",
"deleting": "削除中..."
"deleting": "削除中...",
"auto": "自動"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
@@ -452,6 +453,14 @@
"oneClickInstall": "ワンクリックインストール",
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール",
"localEnvCheck": "ローカル環境チェック",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
"macos": "macOS",
"linux": "Linux"
},
"wslShell": "Shell",
"wslShellFlag": "フラグ",
"installCommandsCopied": "インストールコマンドをコピーしました",
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
"importFailedError": "設定のインポートに失敗しました: {{message}}",
+10 -1
View File
@@ -37,7 +37,8 @@
"search": "查询",
"reset": "重置",
"actions": "操作",
"deleting": "删除中..."
"deleting": "删除中...",
"auto": "自动"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -452,6 +453,14 @@
"oneClickInstall": "一键安装",
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode",
"localEnvCheck": "本地环境检查",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
"macos": "macOS",
"linux": "Linux"
},
"wslShell": "Shell",
"wslShellFlag": "标志",
"installCommandsCopied": "安装命令已复制",
"installCommandsCopyFailed": "复制失败,请手动复制。",
"importFailedError": "导入配置失败:{{message}}",
+10 -2
View File
@@ -169,15 +169,23 @@ export const settingsApi = {
return await invoke("get_auto_launch_status");
},
async getToolVersions(): Promise<
async getToolVersions(
tools?: string[],
wslShellByTool?: Record<
string,
{ wslShell?: string | null; wslShellFlag?: string | null }
>,
): Promise<
Array<{
name: string;
version: string | null;
latest_version: string | null;
error: string | null;
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
wsl_distro: string | null;
}>
> {
return await invoke("get_tool_versions");
return await invoke("get_tool_versions", { tools, wslShellByTool });
},
async getRectifierConfig(): Promise<RectifierConfig> {