From f63a4b3754d2966712a9db40b42242f01baac2a1 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 26 Oct 2025 10:26:32 +0000 Subject: [PATCH] Add OpenCode binary selection with version detection - 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 --- electron/main/ipc.ts | 108 +++++- electron/main/main.ts | 23 +- electron/main/process-manager.ts | 32 +- electron/main/storage.ts | 4 +- electron/preload/index.ts | 21 +- src/App.tsx | 4 +- src/components/folder-selection-view.tsx | 49 ++- src/components/opencode-binary-selector.tsx | 403 ++++++++++++++++++++ src/lib/storage.ts | 32 +- src/stores/instances.ts | 11 +- src/stores/preferences.ts | 52 ++- 11 files changed, 684 insertions(+), 55 deletions(-) create mode 100644 src/components/opencode-binary-selector.tsx diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts index 1400cf5a..ad71b1a9 100644 --- a/electron/main/ipc.ts +++ b/electron/main/ipc.ts @@ -1,8 +1,9 @@ -import { ipcMain, BrowserWindow } from "electron" +import { ipcMain, BrowserWindow, dialog } from "electron" 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 ignore from "ignore" interface Instance { @@ -23,7 +24,20 @@ function generateId(): string { export function setupInstanceIPC(mainWindow: BrowserWindow) { processManager.setMainWindow(mainWindow) - ipcMain.handle("instance:create", async (event, id: string, folder: string) => { + ipcMain.handle("dialog:selectFolder", async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + title: "Select Project Folder", + properties: ["openDirectory"], + }) + + if (result.canceled || !result.filePaths.length) { + return null + } + + return result.filePaths[0] + }) + + ipcMain.handle("instance:create", async (event, id: string, folder: string, binaryPath?: string) => { const instance: Instance = { id, folder, @@ -35,13 +49,13 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { instances.set(id, instance) try { - const { pid, port, binaryPath } = await processManager.spawn(folder, id) + const { pid, port, binaryPath: actualBinaryPath } = await processManager.spawn(folder, id, binaryPath) instance.port = port instance.pid = pid instance.status = "ready" - mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath }) + mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath }) const meta = processManager.getAllProcesses().get(pid) if (meta) { @@ -51,7 +65,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { }) } - return { id, port, pid, binaryPath } + return { id, port, pid, binaryPath: actualBinaryPath } } catch (error) { instance.status = "error" instance.error = error instanceof Error ? error.message : String(error) @@ -128,4 +142,88 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) { return scanDir(workspaceFolder, workspaceFolder) }) + + // OpenCode binary operations + ipcMain.handle("dialog:selectOpenCodeBinary", async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + title: "Select OpenCode Binary", + filters: [ + { name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }) + + if (result.canceled || !result.filePaths.length) { + return null + } + + return result.filePaths[0] + }) + + ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => { + try { + // Special handling for system PATH binary + const isSystemPath = binaryPath === "opencode" + + if (!isSystemPath) { + // Check if file exists and is executable for custom paths + if (!fs.existsSync(binaryPath)) { + return { valid: false, error: "File does not exist" } + } + + const stats = fs.statSync(binaryPath) + if (!stats.isFile()) { + return { valid: false, error: "Path is not a file" } + } + } + + // Try to get version + let version: string | undefined + try { + // Try -v flag first (opencode uses this) + let versionOutput = execSync(`${binaryPath} -v`, { + stdio: "pipe", + encoding: "utf-8", + timeout: 5000, + }) + + version = versionOutput.trim() + } 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" } + } + } catch (error) { + return { valid: false, error: "Binary failed to execute" } + } + + return { valid: true, version } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : String(error), + } + } + }) } diff --git a/electron/main/main.ts b/electron/main/main.ts index e1597139..775c757f 100644 --- a/electron/main/main.ts +++ b/electron/main/main.ts @@ -4,6 +4,9 @@ import { createApplicationMenu } from "./menu" import { setupInstanceIPC } from "./ipc" import { setupStorageIPC } from "./storage" +// Setup IPC handlers before creating windows +setupStorageIPC() + let mainWindow: BrowserWindow | null = null function createWindow() { @@ -28,33 +31,13 @@ function createWindow() { createApplicationMenu(mainWindow) setupInstanceIPC(mainWindow) - setupStorageIPC() mainWindow.on("closed", () => { mainWindow = null }) } -function setupIPC() { - ipcMain.handle("dialog:selectFolder", async () => { - if (!mainWindow) return null - - const result = await dialog.showOpenDialog(mainWindow, { - title: "Select Project Folder", - buttonLabel: "Select", - properties: ["openDirectory"], - }) - - if (result.canceled) { - return null - } - - return result.filePaths[0] || null - }) -} - app.whenReady().then(() => { - setupIPC() createWindow() app.on("activate", () => { diff --git a/electron/main/process-manager.ts b/electron/main/process-manager.ts index d20d8654..5b41e832 100644 --- a/electron/main/process-manager.ts +++ b/electron/main/process-manager.ts @@ -50,14 +50,14 @@ class ProcessManager { } } - async spawn(folder: string, instanceId: string): Promise { + async spawn(folder: string, instanceId: string, binaryPath?: string): Promise { this.validateFolder(folder) - const binaryPath = this.validateOpenCodeBinary() + const actualBinaryPath = binaryPath ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary() - this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`) + this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder} using ${actualBinaryPath}...`) return new Promise((resolve, reject) => { - const child = spawn("opencode", ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], { + const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], { cwd: folder, stdio: ["ignore", "pipe", "pipe"], env: process.env, @@ -103,7 +103,7 @@ class ProcessManager { } this.processes.set(child.pid!, meta) - resolve({ pid: child.pid!, port, binaryPath }) + resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath }) } const meta = this.processes.get(child.pid!) @@ -221,6 +221,28 @@ class ProcessManager { ) } } + + private validateCustomBinary(binaryPath: string): string { + if (!existsSync(binaryPath)) { + throw new Error(`OpenCode binary not found: ${binaryPath}`) + } + + const stats = statSync(binaryPath) + if (!stats.isFile()) { + throw new Error(`Path is not a file: ${binaryPath}`) + } + + // Check if executable (on Unix systems) + if (process.platform !== "win32") { + try { + execSync(`test -x "${binaryPath}"`, { stdio: "pipe" }) + } catch { + throw new Error(`Binary is not executable: ${binaryPath}`) + } + } + + return binaryPath + } } export const processManager = new ProcessManager() diff --git a/electron/main/storage.ts b/electron/main/storage.ts index 7c64ff72..f52f9b4c 100644 --- a/electron/main/storage.ts +++ b/electron/main/storage.ts @@ -51,8 +51,8 @@ function invalidateConfigCache() { export function setupStorageIPC() { ensureDirectories() - ipcMain.handle("storage:getConfigPath", () => CONFIG_FILE) - ipcMain.handle("storage:getInstancesDir", () => INSTANCES_DIR) + ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE) + ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR) ipcMain.handle("storage:readConfigFile", async () => { try { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index af0ac85a..28c1b4a3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -2,7 +2,11 @@ import { contextBridge, ipcRenderer } from "electron" export interface ElectronAPI { selectFolder: () => Promise - createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number; binaryPath: string }> + createInstance: ( + id: string, + folder: string, + binaryPath?: string, + ) => Promise<{ id: string; port: number; pid: number; binaryPath: string }> stopInstance: (pid: number) => Promise onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void onInstanceError: (callback: (data: { id: string; error: string }) => void) => void @@ -15,19 +19,24 @@ export interface ElectronAPI { ) => void onNewInstance: (callback: () => void) => void scanDirectory: (workspaceFolder: string) => Promise + // OpenCode binary operations + selectOpenCodeBinary: () => Promise + validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }> // Storage operations - getConfigPath: () => string - getInstancesDir: () => string + getConfigPath: () => Promise + getInstancesDir: () => Promise readConfigFile: () => Promise writeConfigFile: (content: string) => Promise readInstanceFile: (instanceId: string) => Promise writeInstanceFile: (instanceId: string, content: string) => Promise deleteInstanceFile: (instanceId: string) => Promise + onConfigChanged: (callback: () => void) => () => void } const electronAPI: ElectronAPI = { selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), - createInstance: (id: string, folder: string) => ipcRenderer.invoke("instance:create", id, folder), + createInstance: (id: string, folder: string, binaryPath?: string) => + ipcRenderer.invoke("instance:create", id, folder, binaryPath), stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid), onInstanceStarted: (callback) => { ipcRenderer.on("instance:started", (_, data) => callback(data)) @@ -45,6 +54,9 @@ const electronAPI: ElectronAPI = { ipcRenderer.on("menu:newInstance", () => callback()) }, scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder), + // OpenCode binary operations + selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"), + validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path), // Storage operations getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"), getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"), @@ -56,6 +68,7 @@ const electronAPI: ElectronAPI = { deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename), onConfigChanged: (callback: () => void) => { ipcRenderer.on("storage:configChanged", () => callback()) + return () => ipcRenderer.removeAllListeners("storage:configChanged") }, } diff --git a/src/App.tsx b/src/App.tsx index 14bb60ad..fbf3996f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -183,7 +183,7 @@ const App: Component = () => { return activeSessionId().get(instance.id) || null }) - async function handleSelectFolder(folderPath?: string) { + async function handleSelectFolder(folderPath?: string, binaryPath?: string) { setIsSelectingFolder(true) try { let folder: string | null | undefined = folderPath @@ -196,7 +196,7 @@ const App: Component = () => { } addRecentFolder(folder) - const instanceId = await createInstance(folder) + const instanceId = await createInstance(folder, binaryPath) setHasInstances(true) setShowFolderSelection(false) diff --git a/src/components/folder-selection-view.tsx b/src/components/folder-selection-view.tsx index d03f7b21..e2e86b85 100644 --- a/src/components/folder-selection-view.tsx +++ b/src/components/folder-selection-view.tsx @@ -1,15 +1,18 @@ import { Component, createSignal, Show, For, onMount, onCleanup } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus } from "lucide-solid" -import { recentFolders, removeRecentFolder } from "../stores/preferences" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronDown, ChevronUp } from "lucide-solid" +import { recentFolders, removeRecentFolder, preferences } from "../stores/preferences" +import OpenCodeBinarySelector from "./opencode-binary-selector" interface FolderSelectionViewProps { - onSelectFolder: (folder?: string) => void + onSelectFolder: (folder?: string, binaryPath?: string) => void isLoading?: boolean } const FolderSelectionView: Component = (props) => { const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") + const [showAdvanced, setShowAdvanced] = createSignal(false) + const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const folders = () => recentFolders() @@ -111,11 +114,11 @@ const FolderSelectionView: Component = (props) => { } function handleFolderSelect(path: string) { - props.onSelectFolder(path) + props.onSelectFolder(path, selectedBinary()) } function handleBrowse() { - props.onSelectFolder() + props.onSelectFolder(undefined, selectedBinary()) } function handleRemove(path: string, e?: Event) { @@ -146,7 +149,7 @@ const FolderSelectionView: Component = (props) => {

Select a folder to start coding with AI

-
+
0} fallback={ @@ -159,7 +162,7 @@ const FolderSelectionView: Component = (props) => {
} > -
+

Recent Folders

@@ -221,11 +224,12 @@ const FolderSelectionView: Component = (props) => {

-
+

Browse for Folder

Select any folder on your computer

+
+ + {/* Advanced settings section */} +
+ + + +
+
OpenCode Binary
+ +
+
+
diff --git a/src/components/opencode-binary-selector.tsx b/src/components/opencode-binary-selector.tsx new file mode 100644 index 00000000..92d8413a --- /dev/null +++ b/src/components/opencode-binary-selector.tsx @@ -0,0 +1,403 @@ +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 = (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 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 ( +
+ {/* 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="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" + /> + +
+ + {/* Browse button */} + +
+ + {/* Validation error */} + +
+
+ + {validationError()} +
+
+
+
+ + {/* Recent binaries list */} +
+ 0} + fallback={ +
+ No recent binaries. Add one above or use system PATH. +
+ } + > + + {(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 ( +
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 +
+
+
+
+
+
+
+ + {/* Click outside to close */} + +
setIsOpen(false)} /> + +
+ ) +} + +export default OpenCodeBinarySelector diff --git a/src/lib/storage.ts b/src/lib/storage.ts index d305566c..4a2f0cef 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,8 +1,9 @@ -import type { Preferences, RecentFolder } from "../stores/preferences" +import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences" export interface ConfigData { preferences: Preferences recentFolders: RecentFolder[] + opencodeBinaries: OpenCodeBinary[] } export interface InstanceData { @@ -10,22 +11,38 @@ export interface InstanceData { } export class FileStorage { - private configPath: string - private instancesDir: string + private configPath: string | undefined + private instancesDir: string | undefined private configChangeListeners: Set<() => void> = new Set() + private initialized = false constructor() { - this.configPath = window.electronAPI.getConfigPath() - this.instancesDir = window.electronAPI.getInstancesDir() + this.initialize() + } + + private async initialize() { + if (this.initialized) return + + this.configPath = await window.electronAPI.getConfigPath() + this.instancesDir = await window.electronAPI.getInstancesDir() // Listen for config changes from other instances window.electronAPI.onConfigChanged(() => { this.configChangeListeners.forEach((listener) => listener()) }) + + this.initialized = true + } + + private async ensureInitialized() { + if (!this.initialized) { + await this.initialize() + } } // Config operations async loadConfig(): Promise { + await this.ensureInitialized() try { const content = await window.electronAPI.readConfigFile() return JSON.parse(content) @@ -36,11 +53,13 @@ export class FileStorage { showThinkingBlocks: false, }, recentFolders: [], + opencodeBinaries: [], } } } async saveConfig(config: ConfigData): Promise { + await this.ensureInitialized() try { await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2)) } catch (error) { @@ -51,6 +70,7 @@ export class FileStorage { // Instance operations async loadInstanceData(instanceId: string): Promise { + await this.ensureInitialized() try { const filename = this.instanceIdToFilename(instanceId) const content = await window.electronAPI.readInstanceFile(filename) @@ -64,6 +84,7 @@ export class FileStorage { } async saveInstanceData(instanceId: string, data: InstanceData): Promise { + await this.ensureInitialized() try { const filename = this.instanceIdToFilename(instanceId) await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2)) @@ -74,6 +95,7 @@ export class FileStorage { } async deleteInstanceData(instanceId: string): Promise { + await this.ensureInitialized() try { const filename = this.instanceIdToFilename(instanceId) await window.electronAPI.deleteInstanceFile(filename) diff --git a/src/stores/instances.ts b/src/stores/instances.ts index 707e61fd..522a04a2 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -40,7 +40,7 @@ function removeInstance(id: string) { } } -async function createInstance(folder: string): Promise { +async function createInstance(folder: string, binaryPath?: string): Promise { const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const instance: Instance = { @@ -56,7 +56,12 @@ async function createInstance(folder: string): Promise { addInstance(instance) try { - const { id: returnedId, port, pid, binaryPath } = await window.electronAPI.createInstance(id, folder) + const { + id: returnedId, + port, + pid, + binaryPath: actualBinaryPath, + } = await window.electronAPI.createInstance(id, folder, binaryPath) const client = sdkManager.createClient(port) @@ -65,7 +70,7 @@ async function createInstance(folder: string): Promise { pid, client, status: "ready", - binaryPath, + binaryPath: actualBinaryPath, }) setActiveInstanceId(id) diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index a7826fed..422d9964 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -3,6 +3,13 @@ import { storage, type ConfigData } from "../lib/storage" export interface Preferences { showThinkingBlocks: boolean + lastUsedBinary?: string +} + +export interface OpenCodeBinary { + path: string + version?: string + lastUsed: number } export interface RecentFolder { @@ -18,12 +25,14 @@ const defaultPreferences: Preferences = { const [preferences, setPreferences] = createSignal(defaultPreferences) const [recentFolders, setRecentFolders] = createSignal([]) +const [opencodeBinaries, setOpenCodeBinaries] = createSignal([]) async function loadConfig(): Promise { try { const config = await storage.loadConfig() setPreferences({ ...defaultPreferences, ...config.preferences }) setRecentFolders(config.recentFolders) + setOpenCodeBinaries(config.opencodeBinaries || []) } catch (error) { console.error("Failed to load config:", error) } @@ -34,6 +43,7 @@ async function saveConfig(): Promise { const config: ConfigData = { preferences: preferences(), recentFolders: recentFolders(), + opencodeBinaries: opencodeBinaries(), } await storage.saveConfig(config) } catch (error) { @@ -66,6 +76,35 @@ function removeRecentFolder(path: string): void { saveConfig().catch(console.error) } +function addOpenCodeBinary(path: string, version?: string): void { + const binaries = opencodeBinaries().filter((b) => b.path !== path) + binaries.unshift({ path, version, lastUsed: Date.now() }) + + const trimmed = binaries.slice(0, 10) // Keep max 10 binaries + setOpenCodeBinaries(trimmed) + saveConfig().catch(console.error) +} + +function removeOpenCodeBinary(path: string): void { + const binaries = opencodeBinaries().filter((b) => b.path !== path) + setOpenCodeBinaries(binaries) + saveConfig().catch(console.error) +} + +function updateLastUsedBinary(path: string): void { + updatePreferences({ lastUsedBinary: path }) + + const binaries = opencodeBinaries() + const binary = binaries.find((b) => b.path === path) + if (binary) { + binary.lastUsed = Date.now() + // Move to front + const sorted = [binary, ...binaries.filter((b) => b.path !== path)] + setOpenCodeBinaries(sorted) + saveConfig().catch(console.error) + } +} + // Load config on mount and listen for changes from other instances onMount(() => { loadConfig() @@ -79,4 +118,15 @@ onMount(() => { return unsubscribe }) -export { preferences, updatePreferences, toggleShowThinkingBlocks, recentFolders, addRecentFolder, removeRecentFolder } +export { + preferences, + updatePreferences, + toggleShowThinkingBlocks, + recentFolders, + addRecentFolder, + removeRecentFolder, + opencodeBinaries, + addOpenCodeBinary, + removeOpenCodeBinary, + updateLastUsedBinary, +}