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
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { ipcMain, BrowserWindow } from "electron"
|
import { ipcMain, BrowserWindow, dialog } from "electron"
|
||||||
import { processManager } from "./process-manager"
|
import { processManager } from "./process-manager"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
|
import { execSync } from "child_process"
|
||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
|
|
||||||
interface Instance {
|
interface Instance {
|
||||||
@@ -23,7 +24,20 @@ function generateId(): string {
|
|||||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||||
processManager.setMainWindow(mainWindow)
|
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 = {
|
const instance: Instance = {
|
||||||
id,
|
id,
|
||||||
folder,
|
folder,
|
||||||
@@ -35,13 +49,13 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
|||||||
instances.set(id, instance)
|
instances.set(id, instance)
|
||||||
|
|
||||||
try {
|
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.port = port
|
||||||
instance.pid = pid
|
instance.pid = pid
|
||||||
instance.status = "ready"
|
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)
|
const meta = processManager.getAllProcesses().get(pid)
|
||||||
if (meta) {
|
if (meta) {
|
||||||
@@ -51,7 +65,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id, port, pid, binaryPath }
|
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
instance.error = error instanceof Error ? error.message : String(error)
|
instance.error = error instanceof Error ? error.message : String(error)
|
||||||
@@ -128,4 +142,88 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
|||||||
|
|
||||||
return scanDir(workspaceFolder, workspaceFolder)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { createApplicationMenu } from "./menu"
|
|||||||
import { setupInstanceIPC } from "./ipc"
|
import { setupInstanceIPC } from "./ipc"
|
||||||
import { setupStorageIPC } from "./storage"
|
import { setupStorageIPC } from "./storage"
|
||||||
|
|
||||||
|
// Setup IPC handlers before creating windows
|
||||||
|
setupStorageIPC()
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -28,33 +31,13 @@ function createWindow() {
|
|||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(mainWindow)
|
||||||
setupInstanceIPC(mainWindow)
|
setupInstanceIPC(mainWindow)
|
||||||
setupStorageIPC()
|
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
mainWindow.on("closed", () => {
|
||||||
mainWindow = null
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
setupIPC()
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ class ProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async spawn(folder: string, instanceId: string): Promise<ProcessInfo> {
|
async spawn(folder: string, instanceId: string, binaryPath?: string): Promise<ProcessInfo> {
|
||||||
this.validateFolder(folder)
|
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) => {
|
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,
|
cwd: folder,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -103,7 +103,7 @@ class ProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.processes.set(child.pid!, meta)
|
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!)
|
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()
|
export const processManager = new ProcessManager()
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ function invalidateConfigCache() {
|
|||||||
export function setupStorageIPC() {
|
export function setupStorageIPC() {
|
||||||
ensureDirectories()
|
ensureDirectories()
|
||||||
|
|
||||||
ipcMain.handle("storage:getConfigPath", () => CONFIG_FILE)
|
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
|
||||||
ipcMain.handle("storage:getInstancesDir", () => INSTANCES_DIR)
|
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
|
||||||
|
|
||||||
ipcMain.handle("storage:readConfigFile", async () => {
|
ipcMain.handle("storage:readConfigFile", async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { contextBridge, ipcRenderer } from "electron"
|
|||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
selectFolder: () => Promise<string | null>
|
selectFolder: () => Promise<string | null>
|
||||||
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<void>
|
stopInstance: (pid: number) => Promise<void>
|
||||||
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
|
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
|
||||||
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
|
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
|
||||||
@@ -15,19 +19,24 @@ export interface ElectronAPI {
|
|||||||
) => void
|
) => void
|
||||||
onNewInstance: (callback: () => void) => void
|
onNewInstance: (callback: () => void) => void
|
||||||
scanDirectory: (workspaceFolder: string) => Promise<string[]>
|
scanDirectory: (workspaceFolder: string) => Promise<string[]>
|
||||||
|
// OpenCode binary operations
|
||||||
|
selectOpenCodeBinary: () => Promise<string | null>
|
||||||
|
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
|
||||||
// Storage operations
|
// Storage operations
|
||||||
getConfigPath: () => string
|
getConfigPath: () => Promise<string>
|
||||||
getInstancesDir: () => string
|
getInstancesDir: () => Promise<string>
|
||||||
readConfigFile: () => Promise<string>
|
readConfigFile: () => Promise<string>
|
||||||
writeConfigFile: (content: string) => Promise<void>
|
writeConfigFile: (content: string) => Promise<void>
|
||||||
readInstanceFile: (instanceId: string) => Promise<string>
|
readInstanceFile: (instanceId: string) => Promise<string>
|
||||||
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
|
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
|
||||||
deleteInstanceFile: (instanceId: string) => Promise<void>
|
deleteInstanceFile: (instanceId: string) => Promise<void>
|
||||||
|
onConfigChanged: (callback: () => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
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),
|
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||||
onInstanceStarted: (callback) => {
|
onInstanceStarted: (callback) => {
|
||||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
||||||
@@ -45,6 +54,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.on("menu:newInstance", () => callback())
|
ipcRenderer.on("menu:newInstance", () => callback())
|
||||||
},
|
},
|
||||||
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
|
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
|
// Storage operations
|
||||||
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
||||||
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
||||||
@@ -56,6 +68,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
||||||
onConfigChanged: (callback: () => void) => {
|
onConfigChanged: (callback: () => void) => {
|
||||||
ipcRenderer.on("storage:configChanged", () => callback())
|
ipcRenderer.on("storage:configChanged", () => callback())
|
||||||
|
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const App: Component = () => {
|
|||||||
return activeSessionId().get(instance.id) || null
|
return activeSessionId().get(instance.id) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath?: string) {
|
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
try {
|
try {
|
||||||
let folder: string | null | undefined = folderPath
|
let folder: string | null | undefined = folderPath
|
||||||
@@ -196,7 +196,7 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addRecentFolder(folder)
|
addRecentFolder(folder)
|
||||||
const instanceId = await createInstance(folder)
|
const instanceId = await createInstance(folder, binaryPath)
|
||||||
setHasInstances(true)
|
setHasInstances(true)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Component, createSignal, Show, For, onMount, onCleanup } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronDown, ChevronUp } from "lucide-solid"
|
||||||
import { recentFolders, removeRecentFolder } from "../stores/preferences"
|
import { recentFolders, removeRecentFolder, preferences } from "../stores/preferences"
|
||||||
|
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder?: string) => void
|
onSelectFolder: (folder?: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
|
const [showAdvanced, setShowAdvanced] = createSignal(false)
|
||||||
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
|
|
||||||
@@ -111,11 +114,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderSelect(path: string) {
|
function handleFolderSelect(path: string) {
|
||||||
props.onSelectFolder(path)
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowse() {
|
function handleBrowse() {
|
||||||
props.onSelectFolder()
|
props.onSelectFolder(undefined, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemove(path: string, e?: Event) {
|
function handleRemove(path: string, e?: Event) {
|
||||||
@@ -146,7 +149,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<p class="text-base text-gray-600 dark:text-gray-400">Select a folder to start coding with AI</p>
|
<p class="text-base text-gray-600 dark:text-gray-400">Select a folder to start coding with AI</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 overflow-visible">
|
||||||
<Show
|
<Show
|
||||||
when={folders().length > 0}
|
when={folders().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -159,7 +162,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Recent Folders</h2>
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Recent Folders</h2>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
@@ -221,11 +224,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Browse for Folder</h2>
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Browse for Folder</h2>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Select any folder on your computer</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Select any folder on your computer</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowse}
|
onClick={handleBrowse}
|
||||||
@@ -242,6 +246,35 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced settings section */}
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced())}
|
||||||
|
class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Settings class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Advanced Settings</span>
|
||||||
|
</div>
|
||||||
|
{showAdvanced() ? (
|
||||||
|
<ChevronUp class="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown class="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAdvanced()}>
|
||||||
|
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/50 overflow-visible">
|
||||||
|
<div class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">OpenCode Binary</div>
|
||||||
|
<OpenCodeBinarySelector
|
||||||
|
selectedBinary={selectedBinary()}
|
||||||
|
onBinaryChange={setSelectedBinary}
|
||||||
|
disabled={props.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
403
src/components/opencode-binary-selector.tsx
Normal file
403
src/components/opencode-binary-selector.tsx
Normal file
@@ -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<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
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Preferences, RecentFolder } from "../stores/preferences"
|
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
|
||||||
|
|
||||||
export interface ConfigData {
|
export interface ConfigData {
|
||||||
preferences: Preferences
|
preferences: Preferences
|
||||||
recentFolders: RecentFolder[]
|
recentFolders: RecentFolder[]
|
||||||
|
opencodeBinaries: OpenCodeBinary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceData {
|
export interface InstanceData {
|
||||||
@@ -10,22 +11,38 @@ export interface InstanceData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FileStorage {
|
export class FileStorage {
|
||||||
private configPath: string
|
private configPath: string | undefined
|
||||||
private instancesDir: string
|
private instancesDir: string | undefined
|
||||||
private configChangeListeners: Set<() => void> = new Set()
|
private configChangeListeners: Set<() => void> = new Set()
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configPath = window.electronAPI.getConfigPath()
|
this.initialize()
|
||||||
this.instancesDir = window.electronAPI.getInstancesDir()
|
}
|
||||||
|
|
||||||
|
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
|
// Listen for config changes from other instances
|
||||||
window.electronAPI.onConfigChanged(() => {
|
window.electronAPI.onConfigChanged(() => {
|
||||||
this.configChangeListeners.forEach((listener) => listener())
|
this.configChangeListeners.forEach((listener) => listener())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureInitialized() {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config operations
|
// Config operations
|
||||||
async loadConfig(): Promise<ConfigData> {
|
async loadConfig(): Promise<ConfigData> {
|
||||||
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
const content = await window.electronAPI.readConfigFile()
|
const content = await window.electronAPI.readConfigFile()
|
||||||
return JSON.parse(content)
|
return JSON.parse(content)
|
||||||
@@ -36,11 +53,13 @@ export class FileStorage {
|
|||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
},
|
},
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
|
opencodeBinaries: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveConfig(config: ConfigData): Promise<void> {
|
async saveConfig(config: ConfigData): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
|
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -51,6 +70,7 @@ export class FileStorage {
|
|||||||
|
|
||||||
// Instance operations
|
// Instance operations
|
||||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||||
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
const filename = this.instanceIdToFilename(instanceId)
|
const filename = this.instanceIdToFilename(instanceId)
|
||||||
const content = await window.electronAPI.readInstanceFile(filename)
|
const content = await window.electronAPI.readInstanceFile(filename)
|
||||||
@@ -64,6 +84,7 @@ export class FileStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
const filename = this.instanceIdToFilename(instanceId)
|
const filename = this.instanceIdToFilename(instanceId)
|
||||||
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
|
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
|
||||||
@@ -74,6 +95,7 @@ export class FileStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteInstanceData(instanceId: string): Promise<void> {
|
async deleteInstanceData(instanceId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized()
|
||||||
try {
|
try {
|
||||||
const filename = this.instanceIdToFilename(instanceId)
|
const filename = this.instanceIdToFilename(instanceId)
|
||||||
await window.electronAPI.deleteInstanceFile(filename)
|
await window.electronAPI.deleteInstanceFile(filename)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function removeInstance(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createInstance(folder: string): Promise<string> {
|
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||||
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
const instance: Instance = {
|
const instance: Instance = {
|
||||||
@@ -56,7 +56,12 @@ async function createInstance(folder: string): Promise<string> {
|
|||||||
addInstance(instance)
|
addInstance(instance)
|
||||||
|
|
||||||
try {
|
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)
|
const client = sdkManager.createClient(port)
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ async function createInstance(folder: string): Promise<string> {
|
|||||||
pid,
|
pid,
|
||||||
client,
|
client,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
binaryPath,
|
binaryPath: actualBinaryPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
setActiveInstanceId(id)
|
setActiveInstanceId(id)
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { storage, type ConfigData } from "../lib/storage"
|
|||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
|
lastUsedBinary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeBinary {
|
||||||
|
path: string
|
||||||
|
version?: string
|
||||||
|
lastUsed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentFolder {
|
export interface RecentFolder {
|
||||||
@@ -18,12 +25,14 @@ const defaultPreferences: Preferences = {
|
|||||||
|
|
||||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||||
|
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||||
|
|
||||||
async function loadConfig(): Promise<void> {
|
async function loadConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = await storage.loadConfig()
|
const config = await storage.loadConfig()
|
||||||
setPreferences({ ...defaultPreferences, ...config.preferences })
|
setPreferences({ ...defaultPreferences, ...config.preferences })
|
||||||
setRecentFolders(config.recentFolders)
|
setRecentFolders(config.recentFolders)
|
||||||
|
setOpenCodeBinaries(config.opencodeBinaries || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config:", error)
|
console.error("Failed to load config:", error)
|
||||||
}
|
}
|
||||||
@@ -34,6 +43,7 @@ async function saveConfig(): Promise<void> {
|
|||||||
const config: ConfigData = {
|
const config: ConfigData = {
|
||||||
preferences: preferences(),
|
preferences: preferences(),
|
||||||
recentFolders: recentFolders(),
|
recentFolders: recentFolders(),
|
||||||
|
opencodeBinaries: opencodeBinaries(),
|
||||||
}
|
}
|
||||||
await storage.saveConfig(config)
|
await storage.saveConfig(config)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,6 +76,35 @@ function removeRecentFolder(path: string): void {
|
|||||||
saveConfig().catch(console.error)
|
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
|
// Load config on mount and listen for changes from other instances
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
@@ -79,4 +118,15 @@ onMount(() => {
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
})
|
})
|
||||||
|
|
||||||
export { preferences, updatePreferences, toggleShowThinkingBlocks, recentFolders, addRecentFolder, removeRecentFolder }
|
export {
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
toggleShowThinkingBlocks,
|
||||||
|
recentFolders,
|
||||||
|
addRecentFolder,
|
||||||
|
removeRecentFolder,
|
||||||
|
opencodeBinaries,
|
||||||
|
addOpenCodeBinary,
|
||||||
|
removeOpenCodeBinary,
|
||||||
|
updateLastUsedBinary,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user