diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 2a1c73f3..7915079a 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -3,7 +3,7 @@ import { processManager } from "./process-manager" import { randomBytes } from "crypto" import * as fs from "fs" import * as path from "path" -import { execSync } from "child_process" +import { spawn } from "child_process" import ignore from "ignore" interface Instance { @@ -21,6 +21,44 @@ function generateId(): string { return randomBytes(16).toString("hex") } +function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(binaryPath, ["-v"], { + stdio: ["ignore", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + + const timeout = setTimeout(() => { + child.kill("SIGTERM") + reject(new Error("Version check timed out")) + }, timeoutMs) + + child.stdout?.on("data", (data) => { + stdout += data.toString() + }) + + child.stderr?.on("data", (data) => { + stderr += data.toString() + }) + + child.on("error", (error) => { + clearTimeout(timeout) + reject(error) + }) + + child.on("close", (code) => { + clearTimeout(timeout) + if (code === 0) { + resolve(stdout.trim()) + } else { + reject(new Error(stderr.trim() || `Binary exited with code ${code}`)) + } + }) + }) +} + export function setupInstanceIPC(mainWindow: BrowserWindow) { processManager.setMainWindow(mainWindow) @@ -185,47 +223,16 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { } } - // Try to get version - let version: string | undefined + // Try to get version once via -v flag try { - // Try -v flag first (opencode uses this) - let versionOutput = execSync(`${binaryPath} -v`, { - stdio: "pipe", - encoding: "utf-8", - timeout: 5000, - }) - - version = versionOutput.trim() + const version = await runBinaryVersion(binaryPath) + return { valid: true, version } } catch (error) { - // Version check failed, but binary might still be valid - - try { - let versionOutput = execSync(`${binaryPath} --version`, { - stdio: "pipe", - encoding: "utf-8", - timeout: 5000, - }) - - version = versionOutput.trim() - } catch (fallbackError) {} - } - - // Try to run help command to verify it's actually opencode - try { - const helpOutput = execSync(`${binaryPath} --help`, { - stdio: "pipe", - encoding: "utf-8", - timeout: 5000, - }) - - if (!helpOutput.toLowerCase().includes("opencode")) { - return { valid: false, error: "Not an OpenCode binary" } + return { + valid: false, + error: error instanceof Error ? error.message : String(error), } - } catch (error) { - return { valid: false, error: "Binary failed to execute" } } - - return { valid: true, version } } catch (error) { return { valid: false, diff --git a/src/components/advanced-settings-modal.tsx b/src/components/advanced-settings-modal.tsx new file mode 100644 index 00000000..5ae364a4 --- /dev/null +++ b/src/components/advanced-settings-modal.tsx @@ -0,0 +1,63 @@ +import { Component } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import OpenCodeBinarySelector from "./opencode-binary-selector" +import EnvironmentVariablesEditor from "./environment-variables-editor" + +interface AdvancedSettingsModalProps { + open: boolean + onClose: () => void + selectedBinary: string + onBinaryChange: (binary: string) => void + isLoading?: boolean +} + +const AdvancedSettingsModal: Component = (props) => { + return ( + !open && props.onClose()}> + + +
+ +
+ Advanced Settings + + Configure the OpenCode binary and environment variables used when launching new instances. + +
+ +
+ + +
+
+

Environment Variables

+

Applied whenever a new OpenCode instance starts

+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ ) +} + +export default AdvancedSettingsModal diff --git a/src/components/folder-selection-view.tsx b/src/components/folder-selection-view.tsx index bfb39ed1..08e1b4fb 100644 --- a/src/components/folder-selection-view.tsx +++ b/src/components/folder-selection-view.tsx @@ -1,8 +1,7 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronDown, ChevronUp } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid" import { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } from "../stores/preferences" -import OpenCodeBinarySelector from "./opencode-binary-selector" -import EnvironmentVariablesEditor from "./environment-variables-editor" +import AdvancedSettingsModal from "./advanced-settings-modal" import Kbd from "./kbd" interface FolderSelectionViewProps { @@ -13,7 +12,7 @@ interface FolderSelectionViewProps { const FolderSelectionView: Component = (props) => { const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") - const [showAdvanced, setShowAdvanced] = createSignal(false) + const [isAdvancedModalOpen, setIsAdvancedModalOpen] = createSignal(false) const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") let recentListRef: HTMLDivElement | undefined @@ -198,10 +197,11 @@ const FolderSelectionView: Component = (props) => { } return ( -
+ <> +
= (props) => { {/* Advanced settings section */}
- - -
-
-
OpenCode Binary
- -
- -
- -
-
-
+
+ Configure the OpenCode binary and environment variables. +
@@ -385,6 +367,15 @@ const FolderSelectionView: Component = (props) => {
+ + setIsAdvancedModalOpen(false)} + selectedBinary={selectedBinary()} + onBinaryChange={handleBinaryChange} + isLoading={props.isLoading} + /> + ) } diff --git a/src/components/opencode-binary-selector.tsx b/src/components/opencode-binary-selector.tsx index fa34c44f..3c33782b 100644 --- a/src/components/opencode-binary-selector.tsx +++ b/src/components/opencode-binary-selector.tsx @@ -1,154 +1,148 @@ -import { Component, createSignal, Show, For, onMount, createEffect, onCleanup } from "solid-js" -import { ChevronDown, ChevronUp, FolderOpen, Trash2, Check, AlertCircle, Loader2 } from "lucide-solid" +import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid" import { opencodeBinaries, addOpenCodeBinary, removeOpenCodeBinary, preferences, - updateLastUsedBinary, + updatePreferences, } from "../stores/preferences" +interface BinaryOption { + path: string + version?: string + lastUsed?: number + isDefault?: boolean +} + interface OpenCodeBinarySelectorProps { selectedBinary: string onBinaryChange: (binary: string) => void disabled?: boolean + isVisible?: boolean } const OpenCodeBinarySelector: Component = (props) => { - const [isOpen, setIsOpen] = createSignal(false) const [customPath, setCustomPath] = createSignal("") const [validating, setValidating] = createSignal(false) const [validationError, setValidationError] = createSignal(null) - const [versionInfo, setVersionInfo] = createSignal>(new Map()) - const [validatingPaths, setValidatingPaths] = createSignal>(new Set()) - let buttonRef: HTMLButtonElement | undefined + const [versionInfo, setVersionInfo] = createSignal>(new Map()) + const [validatingPaths, setValidatingPaths] = createSignal>(new Set()) const binaries = () => opencodeBinaries() const lastUsedBinary = () => preferences().lastUsedBinary - // Set initial selected binary + const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) + + const binaryOptions = createMemo(() => [{ path: "opencode", isDefault: true }, ...customBinaries()]) + + const currentSelectionPath = () => props.selectedBinary || "opencode" + 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) - } + } else if (!props.selectedBinary) { + const firstBinary = binaries()[0] + if (firstBinary) { + props.onBinaryChange(firstBinary.path) } } + }) - document.addEventListener("click", handleClickOutside) - onCleanup(() => { - document.removeEventListener("click", handleClickOutside) - // Clean up validating state on unmount - setValidatingPaths(new Set()) - setValidating(false) + createEffect(() => { + const cache = new Map(versionInfo()) + let updated = false + + binaries().forEach((binary) => { + if (binary.version && !cache.has(binary.path)) { + cache.set(binary.path, binary.version) + updated = true + } }) + + if (updated) { + setVersionInfo(cache) + } + }) + + createEffect(() => { + if (!props.isVisible) return + const cache = versionInfo() + const pathsToValidate = ["opencode", ...customBinaries().map((binary) => binary.path)].filter( + (path) => !cache.has(path), + ) + + if (pathsToValidate.length === 0) return + + setTimeout(() => { + pathsToValidate.forEach((path) => { + validateBinary(path).catch(console.error) + }) + }, 0) + }) + + onCleanup(() => { + setValidatingPaths(new Set()) + setValidating(false) }) async function validateBinary(path: string): Promise<{ valid: boolean; version?: string; error?: string }> { - // Prevent duplicate validation calls + if (versionInfo().has(path)) { + const cachedVersion = versionInfo().get(path) + return cachedVersion ? { valid: true, version: cachedVersion } : { valid: true } + } + 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 + const next = new Set(prev) + next.delete(path) + if (next.size === 0) { + setValidating(false) + } + return next }) - - // 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 (!path) return - if (validation.valid) { - addOpenCodeBinary(path, validation.version) - props.onBinaryChange(path) - updateLastUsedBinary(path) - setCustomPath("") - } else { - setValidationError(validation.error || "Invalid OpenCode binary") - } - } + setCustomPath(path) + await handleValidateAndAdd(path) } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to select binary") } } - async function handleCustomPathSubmit() { - const path = customPath().trim() - if (!path) return - + async function handleValidateAndAdd(path: string) { const validation = await validateBinary(path) if (validation.valid) { addOpenCodeBinary(path, validation.version) props.onBinaryChange(path) - updateLastUsedBinary(path) + updatePreferences({ lastUsedBinary: path }) setCustomPath("") setValidationError(null) } else { @@ -156,27 +150,32 @@ const OpenCodeBinarySelector: Component = (props) = } } - function handleSelectBinary(path: string) { - props.onBinaryChange(path) - updateLastUsedBinary(path) - setIsOpen(false) + async function handleCustomPathSubmit() { + const path = customPath().trim() + if (!path) return + await handleValidateAndAdd(path) } - function handleRemoveBinary(path: string, e: Event) { - e.stopPropagation() + function handleSelectBinary(path: string) { + if (props.disabled) return + if (path === props.selectedBinary) return + props.onBinaryChange(path) + updatePreferences({ lastUsedBinary: path }) + } + + function handleRemoveBinary(path: string, event: Event) { + event.stopPropagation() + if (props.disabled) return 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 - } + props.onBinaryChange("opencode") + updatePreferences({ lastUsedBinary: "opencode" }) } } - function formatRelativeTime(timestamp: number): string { + function formatRelativeTime(timestamp?: number): string { + if (!timestamp) return "" const seconds = Math.floor((Date.now() - timestamp) / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) @@ -190,220 +189,133 @@ const OpenCodeBinarySelector: Component = (props) = 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 + return parts[parts.length - 1] ?? path } - function handleButtonClick() { - setIsOpen(!isOpen()) - } + const isPathValidating = (path: string) => validatingPaths().has(path) return ( -
- {/* Main selector button */} - - - {/* Dropdown */} - -
-
-
OpenCode Binary Selection
- - {/* Custom path input */} -
-
- 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="selector-input" - /> - -
- - {/* Browse button */} - -
- - {/* Validation error */} - -
-
- - {validationError()} -
-
-
+ +
+ + Checking versions…
+
+
- {/* Recent binaries list */} -
- 0} - fallback={ -
- No recent binaries. Add one above or use system PATH. -
+
+
+ setCustomPath(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleCustomPathSubmit() } - > - - {(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 - } + }} + disabled={props.disabled} + placeholder="Enter path to opencode binary…" + class="selector-input" + /> + +
- return ( -
handleSelectBinary(binary.path)} - > -
-
- } - > - - -
-
- {getDisplayName(binary.path)} -
-
- - v{version()} - - - {formatRelativeTime(binary.lastUsed)} - -
-
-
- -
-
- ) - }} - - -
+ - {/* Default option */} -
-
handleSelectBinary("opencode")} - > -
- } - > - - -
-
opencode (system PATH)
-
- - v{versionInfo().get("opencode")} - - - Checking... - - Use binary from system PATH -
-
-
+ +
+
+ + {validationError()}
-
- + +
- {/* Click outside to close */} - -
setIsOpen(false)} /> - +
+ + {(binary) => { + const isDefault = binary.isDefault + const versionLabel = () => versionInfo().get(binary.path) ?? binary.version + + return ( +
+ + + + +
+ ) + }} +
+
) } diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index 55a2dc1f..4e63e6ed 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -45,13 +45,24 @@ const defaultPreferences: Preferences = { const [preferences, setPreferences] = createSignal(defaultPreferences) const [recentFolders, setRecentFolders] = createSignal([]) const [opencodeBinaries, setOpenCodeBinaries] = createSignal([]) +let cachedConfig: ConfigData = { + preferences: defaultPreferences, + recentFolders: [], + opencodeBinaries: [], +} async function loadConfig(): Promise { try { const config = await storage.loadConfig() - setPreferences({ ...defaultPreferences, ...config.preferences }) - setRecentFolders(config.recentFolders) - setOpenCodeBinaries(config.opencodeBinaries || []) + cachedConfig = { + ...config, + preferences: { ...defaultPreferences, ...config.preferences }, + recentFolders: config.recentFolders || [], + opencodeBinaries: config.opencodeBinaries || [], + } + setPreferences(cachedConfig.preferences) + setRecentFolders(cachedConfig.recentFolders) + setOpenCodeBinaries(cachedConfig.opencodeBinaries) } catch (error) { console.error("Failed to load config:", error) } @@ -60,10 +71,12 @@ async function loadConfig(): Promise { async function saveConfig(): Promise { try { const config: ConfigData = { + ...cachedConfig, preferences: preferences(), recentFolders: recentFolders(), opencodeBinaries: opencodeBinaries(), } + cachedConfig = config await storage.saveConfig(config) } catch (error) { console.error("Failed to save config:", error) @@ -102,7 +115,9 @@ function removeRecentFolder(path: string): void { function addOpenCodeBinary(path: string, version?: string): void { const binaries = opencodeBinaries().filter((b) => b.path !== path) - binaries.unshift({ path, version, lastUsed: Date.now() }) + const lastUsed = Date.now() + const binaryEntry: OpenCodeBinary = version ? { path, version, lastUsed } : { path, lastUsed } + binaries.unshift(binaryEntry) const trimmed = binaries.slice(0, 10) // Keep max 10 binaries setOpenCodeBinaries(trimmed)