- Add binary selector component with dropdown and validation - Support custom OpenCode binary paths and system PATH binary - Async version checking for all binaries when selector opens - Store recent binaries with timestamps and version info - Enhanced instance creation to use selected binary - Improved storage system for binary preferences - Fixed validation to handle system PATH binaries properly
404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
import { Component, createSignal, Show, For, onMount, createEffect, onCleanup } from "solid-js"
|
|
import { ChevronDown, ChevronUp, FolderOpen, Trash2, Check, AlertCircle, Loader2 } from "lucide-solid"
|
|
import {
|
|
opencodeBinaries,
|
|
addOpenCodeBinary,
|
|
removeOpenCodeBinary,
|
|
preferences,
|
|
updateLastUsedBinary,
|
|
} from "../stores/preferences"
|
|
|
|
interface OpenCodeBinarySelectorProps {
|
|
selectedBinary: string
|
|
onBinaryChange: (binary: string) => void
|
|
disabled?: boolean
|
|
}
|
|
|
|
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
|
const [isOpen, setIsOpen] = createSignal(false)
|
|
const [customPath, setCustomPath] = createSignal("")
|
|
const [validating, setValidating] = createSignal(false)
|
|
const [validationError, setValidationError] = createSignal<string | null>(null)
|
|
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map())
|
|
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set())
|
|
let buttonRef: HTMLButtonElement | undefined
|
|
|
|
const binaries = () => opencodeBinaries()
|
|
const lastUsedBinary = () => preferences().lastUsedBinary
|
|
|
|
// Set initial selected binary
|
|
createEffect(() => {
|
|
console.log(
|
|
`[BinarySelector] Component effect - selectedBinary: ${props.selectedBinary}, lastUsed: ${lastUsedBinary()}, binaries count: ${binaries().length}`,
|
|
)
|
|
if (!props.selectedBinary && lastUsedBinary()) {
|
|
props.onBinaryChange(lastUsedBinary()!)
|
|
} else if (!props.selectedBinary && binaries().length > 0) {
|
|
props.onBinaryChange(binaries()[0].path)
|
|
}
|
|
})
|
|
|
|
// Validate all binaries when selector opens (only once)
|
|
createEffect(() => {
|
|
if (isOpen()) {
|
|
const pathsToValidate = ["opencode", ...binaries().map((b) => b.path)]
|
|
|
|
// Use setTimeout to break the reactive cycle and validate once
|
|
setTimeout(() => {
|
|
pathsToValidate.forEach((path) => {
|
|
validateBinary(path).catch(console.error)
|
|
})
|
|
}, 0)
|
|
}
|
|
})
|
|
|
|
// Click outside handler
|
|
onMount(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (buttonRef && !buttonRef.contains(event.target as Node)) {
|
|
const dropdown = document.querySelector("[data-binary-dropdown]")
|
|
if (dropdown && !dropdown.contains(event.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener("click", handleClickOutside)
|
|
onCleanup(() => {
|
|
document.removeEventListener("click", handleClickOutside)
|
|
// Clean up validating state on unmount
|
|
setValidatingPaths(new Set())
|
|
setValidating(false)
|
|
})
|
|
})
|
|
|
|
async function validateBinary(path: string): Promise<{ valid: boolean; version?: string; error?: string }> {
|
|
// Prevent duplicate validation calls
|
|
if (validatingPaths().has(path)) {
|
|
console.log(`[BinarySelector] Already validating ${path}, skipping...`)
|
|
return { valid: false, error: "Already validating" }
|
|
}
|
|
|
|
try {
|
|
// Add to validating set
|
|
setValidatingPaths((prev) => new Set(prev).add(path))
|
|
setValidating(true)
|
|
setValidationError(null)
|
|
|
|
console.log(`[BinarySelector] Starting validation for: ${path}`)
|
|
|
|
const result = await window.electronAPI.validateOpenCodeBinary(path)
|
|
console.log(`[BinarySelector] Validation result:`, result)
|
|
|
|
if (result.valid && result.version) {
|
|
const updatedVersionInfo = new Map(versionInfo())
|
|
updatedVersionInfo.set(path, result.version)
|
|
setVersionInfo(updatedVersionInfo)
|
|
console.log(`[BinarySelector] Updated version info for ${path}: ${result.version}`)
|
|
} else {
|
|
console.log(`[BinarySelector] No valid version returned for ${path}`)
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error(`[BinarySelector] Validation error for ${path}:`, error)
|
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
} finally {
|
|
// Remove from validating set
|
|
setValidatingPaths((prev) => {
|
|
const newSet = new Set(prev)
|
|
newSet.delete(path)
|
|
return newSet
|
|
})
|
|
|
|
// Only set validating to false if no other paths are being validated
|
|
if (validatingPaths().size <= 1) {
|
|
setValidating(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleBrowseBinary() {
|
|
try {
|
|
const path = await window.electronAPI.selectOpenCodeBinary()
|
|
if (path) {
|
|
setCustomPath(path)
|
|
const validation = await validateBinary(path)
|
|
|
|
if (validation.valid) {
|
|
addOpenCodeBinary(path, validation.version)
|
|
props.onBinaryChange(path)
|
|
updateLastUsedBinary(path)
|
|
setCustomPath("")
|
|
} else {
|
|
setValidationError(validation.error || "Invalid OpenCode binary")
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
|
|
}
|
|
}
|
|
|
|
async function handleCustomPathSubmit() {
|
|
const path = customPath().trim()
|
|
if (!path) return
|
|
|
|
const validation = await validateBinary(path)
|
|
|
|
if (validation.valid) {
|
|
addOpenCodeBinary(path, validation.version)
|
|
props.onBinaryChange(path)
|
|
updateLastUsedBinary(path)
|
|
setCustomPath("")
|
|
setValidationError(null)
|
|
} else {
|
|
setValidationError(validation.error || "Invalid OpenCode binary")
|
|
}
|
|
}
|
|
|
|
function handleSelectBinary(path: string) {
|
|
props.onBinaryChange(path)
|
|
updateLastUsedBinary(path)
|
|
setIsOpen(false)
|
|
}
|
|
|
|
function handleRemoveBinary(path: string, e: Event) {
|
|
e.stopPropagation()
|
|
removeOpenCodeBinary(path)
|
|
|
|
if (props.selectedBinary === path) {
|
|
const remaining = binaries().filter((b) => b.path !== path)
|
|
if (remaining.length > 0) {
|
|
handleSelectBinary(remaining[0].path)
|
|
} else {
|
|
props.onBinaryChange("opencode") // Default to system PATH
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatRelativeTime(timestamp: number): string {
|
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
|
const minutes = Math.floor(seconds / 60)
|
|
const hours = Math.floor(minutes / 60)
|
|
const days = Math.floor(hours / 24)
|
|
|
|
if (days > 0) return `${days}d ago`
|
|
if (hours > 0) return `${hours}h ago`
|
|
if (minutes > 0) return `${minutes}m ago`
|
|
return "just now"
|
|
}
|
|
|
|
function getDisplayName(path: string): string {
|
|
if (path === "opencode") return "opencode (system PATH)"
|
|
|
|
// Extract just the binary name from path
|
|
const parts = path.split(/[/\\]/)
|
|
const name = parts[parts.length - 1]
|
|
|
|
// If it's the same as default, show full path
|
|
if (name === "opencode") {
|
|
return path
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
function handleButtonClick() {
|
|
setIsOpen(!isOpen())
|
|
}
|
|
|
|
return (
|
|
<div class="relative" style={{ position: "relative" }}>
|
|
{/* Main selector button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleButtonClick}
|
|
disabled={props.disabled}
|
|
class="w-full px-3 py-2 text-left bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm hover:border-gray-400 dark:hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between"
|
|
ref={buttonRef}
|
|
>
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
<Show when={validating()} fallback={<FolderOpen class="w-4 h-4 text-gray-400 dark:text-gray-500" />}>
|
|
<Loader2 class="w-4 h-4 text-blue-500 animate-spin" />
|
|
</Show>
|
|
<span class="text-sm text-gray-900 dark:text-gray-100 truncate">
|
|
{getDisplayName(props.selectedBinary || "opencode")}
|
|
</span>
|
|
<Show when={versionInfo().get(props.selectedBinary)}>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">v{versionInfo().get(props.selectedBinary)}</span>
|
|
</Show>
|
|
</div>
|
|
<ChevronDown
|
|
class={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform ${isOpen() ? "rotate-180" : ""}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
<Show when={isOpen()}>
|
|
<div
|
|
data-binary-dropdown
|
|
class="absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-96 overflow-y-auto"
|
|
style={{
|
|
position: "absolute",
|
|
"z-index": 500,
|
|
"max-height": "24rem",
|
|
}}
|
|
>
|
|
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">OpenCode Binary Selection</div>
|
|
|
|
{/* Custom path input */}
|
|
<div class="space-y-2">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={customPath()}
|
|
onInput={(e) => setCustomPath(e.currentTarget.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleCustomPathSubmit()
|
|
} else if (e.key === "Escape") {
|
|
setCustomPath("")
|
|
setValidationError(null)
|
|
}
|
|
}}
|
|
placeholder="Enter path to opencode binary..."
|
|
class="flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
<button
|
|
onClick={handleCustomPathSubmit}
|
|
disabled={!customPath().trim() || validating()}
|
|
class="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
{/* Browse button */}
|
|
<button
|
|
onClick={handleBrowseBinary}
|
|
disabled={validating()}
|
|
class="w-full px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<FolderOpen class="w-4 h-4" />
|
|
Browse for Binary...
|
|
</button>
|
|
</div>
|
|
|
|
{/* Validation error */}
|
|
<Show when={validationError()}>
|
|
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
|
|
<div class="flex items-start gap-2">
|
|
<AlertCircle class="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
|
<span class="text-xs text-red-700 dark:text-red-400">{validationError()}</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Recent binaries list */}
|
|
<div class="max-h-60 overflow-y-auto">
|
|
<Show
|
|
when={binaries().length > 0}
|
|
fallback={
|
|
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
No recent binaries. Add one above or use system PATH.
|
|
</div>
|
|
}
|
|
>
|
|
<For each={binaries()}>
|
|
{(binary) => {
|
|
const isSelected = () => props.selectedBinary === binary.path
|
|
const version = () => {
|
|
const ver = versionInfo().get(binary.path)
|
|
console.log(`[BinarySelector] Rendering version for ${binary.path}: ${ver || "undefined"}`)
|
|
return ver
|
|
}
|
|
|
|
return (
|
|
<div
|
|
class={`px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
|
|
isSelected() ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
|
}`}
|
|
onClick={() => handleSelectBinary(binary.path)}
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
<Show
|
|
when={isSelected()}
|
|
fallback={<FolderOpen class="w-4 h-4 text-gray-400 dark:text-gray-500" />}
|
|
>
|
|
<Check class="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
</Show>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
{getDisplayName(binary.path)}
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-0.5">
|
|
<Show when={version()}>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">v{version()}</span>
|
|
</Show>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
{formatRelativeTime(binary.lastUsed)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => handleRemoveBinary(binary.path, e)}
|
|
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all"
|
|
title="Remove binary"
|
|
>
|
|
<Trash2 class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Default option */}
|
|
<div class="p-2 border-t border-gray-200 dark:border-gray-700">
|
|
<div
|
|
class={`px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded ${
|
|
props.selectedBinary === "opencode" ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
|
}`}
|
|
onClick={() => handleSelectBinary("opencode")}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<Show
|
|
when={props.selectedBinary === "opencode"}
|
|
fallback={<FolderOpen class="w-4 h-4 text-gray-400 dark:text-gray-500" />}
|
|
>
|
|
<Check class="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
</Show>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">opencode (system PATH)</div>
|
|
<div class="flex items-center gap-2 mt-0.5">
|
|
<Show when={versionInfo().get("opencode")}>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">v{versionInfo().get("opencode")}</span>
|
|
</Show>
|
|
<Show when={!versionInfo().get("opencode") && validating()}>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">Checking...</span>
|
|
</Show>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">Use binary from system PATH</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Click outside to close */}
|
|
<Show when={isOpen()}>
|
|
<div class="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
|
</Show>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default OpenCodeBinarySelector
|