feat(claude-binary): implement robust version selector with enhanced binary detection

This commit provides a comprehensive solution to Claude binary detection issues
by implementing a user-friendly version selector UI and improving the binary
discovery logic. It addresses all concerns raised in multiple PRs and comments.

Changes:
- Add ClaudeVersionSelector component for selecting from multiple installations
- Update ClaudeBinaryDialog to use version selector instead of manual path input
- Fix unused variable warning in production builds (claude.rs:442)
- Improve select_best_installation to handle production build restrictions
- Add listClaudeInstallations API endpoint to fetch all available installations
- Make Claude version indicator clickable to navigate to Settings
- Move Claude installation selector to General tab in Settings (per user request)
- Enhance dialog UX with loading states and clear installation instructions
- Add Radix UI radio-group dependency for version selector

Fixes:
- Production build warning about unused claude_path variable
- Version detection failures in production builds due to process restrictions
- Poor UX when Claude binary is not found (now shows helpful dialog)
- Inability to easily switch between multiple Claude installations

This implementation takes inspiration from:
- PR #3: Version selector dropdown approach (preferred by users)
- PR #4: Binary detection improvements and path validation
- PR #39: Additional detection methods and error handling
- Commit 5a29f9a: Shared claude binary detection module architecture

Addresses feedback from:
- getAsterisk/claudia#4 (comment): User preference for dropdown selector
- Production build restrictions that prevent version detection
- Need for better error handling when Claude is not installed

The solution provides a seamless experience whether Claude is installed via:
- npm/yarn/bun global installation
- nvm-managed Node.js versions
- Homebrew on macOS
- System-wide installation
- Local user installation (~/.local/bin, etc.)

Refs: #3, #4, #39, 5a29f9a
This commit is contained in:
Mufeed VH
2025-06-25 02:49:24 +05:30
parent 97290e5665
commit c48a63f170
14 changed files with 556 additions and 77 deletions

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useState } from "react";
import { api, type ClaudeInstallation } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Loader2, Terminal, Package, Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface ClaudeVersionSelectorProps {
/**
* Currently selected Claude installation path
*/
selectedPath?: string | null;
/**
* Callback when a Claude installation is selected
*/
onSelect: (installation: ClaudeInstallation) => void;
/**
* Optional className for styling
*/
className?: string;
/**
* Whether to show a save button (for settings page)
*/
showSaveButton?: boolean;
/**
* Callback when save button is clicked
*/
onSave?: () => void;
/**
* Whether the save operation is in progress
*/
isSaving?: boolean;
}
/**
* ClaudeVersionSelector component for selecting Claude Code installations
*
* @example
* <ClaudeVersionSelector
* selectedPath={currentPath}
* onSelect={(installation) => setSelectedInstallation(installation)}
* />
*/
export const ClaudeVersionSelector: React.FC<ClaudeVersionSelectorProps> = ({
selectedPath,
onSelect,
className,
showSaveButton = false,
onSave,
isSaving = false,
}) => {
const [installations, setInstallations] = useState<ClaudeInstallation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
useEffect(() => {
loadInstallations();
}, []);
useEffect(() => {
// Update selected installation when selectedPath changes
if (selectedPath && installations.length > 0) {
const found = installations.find(i => i.path === selectedPath);
if (found) {
setSelectedInstallation(found);
}
}
}, [selectedPath, installations]);
const loadInstallations = async () => {
try {
setLoading(true);
setError(null);
const foundInstallations = await api.listClaudeInstallations();
setInstallations(foundInstallations);
// If we have a selected path, find and select it
if (selectedPath) {
const found = foundInstallations.find(i => i.path === selectedPath);
if (found) {
setSelectedInstallation(found);
}
} else if (foundInstallations.length > 0) {
// Auto-select the first (best) installation
setSelectedInstallation(foundInstallations[0]);
onSelect(foundInstallations[0]);
}
} catch (err) {
console.error("Failed to load Claude installations:", err);
setError(err instanceof Error ? err.message : "Failed to load Claude installations");
} finally {
setLoading(false);
}
};
const handleSelect = (installation: ClaudeInstallation) => {
setSelectedInstallation(installation);
onSelect(installation);
};
const getSourceIcon = (source: string) => {
if (source.includes("nvm")) return <Package className="w-4 h-4" />;
return <Terminal className="w-4 h-4" />;
};
const getSourceLabel = (source: string) => {
if (source === "which") return "System PATH";
if (source === "homebrew") return "Homebrew";
if (source === "system") return "System";
if (source.startsWith("nvm")) return source.replace("nvm ", "NVM ");
if (source === "local-bin") return "Local bin";
if (source === "claude-local") return "Claude local";
if (source === "npm-global") return "NPM global";
if (source === "yarn" || source === "yarn-global") return "Yarn";
if (source === "bun") return "Bun";
return source;
};
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<Card className={cn("p-4", className)}>
<div className="text-sm text-destructive">{error}</div>
</Card>
);
}
if (installations.length === 0) {
return (
<Card className={cn("p-4", className)}>
<div className="text-sm text-muted-foreground">
No Claude Code installations found on your system.
</div>
</Card>
);
}
return (
<div className={cn("space-y-4", className)}>
<div>
<Label className="text-sm font-medium mb-3 block">
Select Claude Code Installation
</Label>
<RadioGroup
value={selectedInstallation?.path}
onValueChange={(value: string) => {
const installation = installations.find(i => i.path === value);
if (installation) {
handleSelect(installation);
}
}}
>
<div className="space-y-2">
{installations.map((installation) => (
<Card
key={installation.path}
className={cn(
"relative cursor-pointer transition-colors",
selectedInstallation?.path === installation.path
? "border-primary"
: "hover:border-muted-foreground/50"
)}
onClick={() => handleSelect(installation)}
>
<div className="flex items-start p-4">
<RadioGroupItem
value={installation.path}
id={installation.path}
className="mt-1"
/>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2 mb-1">
{getSourceIcon(installation.source)}
<span className="font-medium text-sm">
{getSourceLabel(installation.source)}
</span>
{installation.version && (
<Badge variant="secondary" className="text-xs">
v{installation.version}
</Badge>
)}
{selectedPath === installation.path && (
<Badge variant="default" className="text-xs">
<Check className="w-3 h-3 mr-1" />
Current
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono break-all">
{installation.path}
</p>
</div>
</div>
</Card>
))}
</div>
</RadioGroup>
</div>
{showSaveButton && onSave && (
<div className="flex justify-end">
<Button
onClick={onSave}
disabled={!selectedInstallation || isSaving}
size="sm"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Selection"
)}
</Button>
</div>
)}
</div>
);
};