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

@@ -6,9 +6,10 @@ use log::{info, warn, debug, error};
use anyhow::Result;
use std::cmp::Ordering;
use tauri::Manager;
use serde::{Serialize, Deserialize};
/// Represents a Claude installation with metadata
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeInstallation {
/// Full path to the Claude binary
pub path: String,
@@ -68,6 +69,55 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
}
}
/// Discovers all available Claude installations and returns them for selection
/// This allows UI to show a version selector
pub fn discover_claude_installations() -> Vec<ClaudeInstallation> {
info!("Discovering all Claude installations...");
let installations = discover_all_installations();
// Sort by version (highest first), then by source preference
let mut sorted = installations;
sorted.sort_by(|a, b| {
match (&a.version, &b.version) {
(Some(v1), Some(v2)) => {
// Compare versions in descending order (newest first)
match compare_versions(v2, v1) {
Ordering::Equal => {
// If versions are equal, prefer by source
source_preference(a).cmp(&source_preference(b))
}
other => other
}
}
(Some(_), None) => Ordering::Less, // Version comes before no version
(None, Some(_)) => Ordering::Greater,
(None, None) => source_preference(a).cmp(&source_preference(b))
}
});
sorted
}
/// Returns a preference score for installation sources (lower is better)
fn source_preference(installation: &ClaudeInstallation) -> u8 {
match installation.source.as_str() {
"which" => 1,
"homebrew" => 2,
"system" => 3,
source if source.starts_with("nvm") => 4,
"local-bin" => 5,
"claude-local" => 6,
"npm-global" => 7,
"yarn" | "yarn-global" => 8,
"bun" => 9,
"node-modules" => 10,
"home-bin" => 11,
"PATH" => 12,
_ => 13,
}
}
/// Discovers all Claude installations on the system
fn discover_all_installations() -> Vec<ClaudeInstallation> {
let mut installations = Vec::new();
@@ -263,19 +313,25 @@ fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
/// Select the best installation based on version
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
// In production builds, version information may not be retrievable because
// spawning external processes can be restricted. We therefore no longer
// discard installations that lack a detected version the mere presence
// of a readable binary on disk is enough to consider it valid. We still
// prefer binaries with version information when it is available so that
// in development builds we keep the previous behaviour of picking the
// most recent version.
installations.into_iter()
.filter(|i| {
// Prefer installations with known versions
i.version.is_some() || i.path == "claude"
})
.max_by(|a, b| {
// First compare by version presence
match (&a.version, &b.version) {
// If both have versions, compare them semantically.
(Some(v1), Some(v2)) => compare_versions(v1, v2),
// Prefer the entry that actually has version information.
(Some(_), None) => Ordering::Greater,
(None, Some(_)) => Ordering::Less,
// Neither have version info: prefer the one that is not just
// the bare "claude" lookup from PATH, because that may fail
// at runtime if PATH is sandbox-stripped.
(None, None) => {
// Both have no version, prefer non-PATH entries
if a.path == "claude" && b.path != "claude" {
Ordering::Less
} else if a.path != "claude" && b.path == "claude" {