Add CLI server and move UI to HTTP API
This commit is contained in:
@@ -85,22 +85,16 @@ const App: Component = () => {
|
||||
|
||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||
|
||||
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
return
|
||||
}
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
let folder: string | null | undefined = folderPath
|
||||
|
||||
if (!folder) {
|
||||
folder = await window.electronAPI.selectFolder()
|
||||
if (!folder) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
addRecentFolder(folder)
|
||||
addRecentFolder(folderPath)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folder, selectedBinary)
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
@@ -129,8 +123,6 @@ const App: Component = () => {
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
} else {
|
||||
void handleSelectFolder()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
@@ -17,7 +18,7 @@ interface FilePickerProps {
|
||||
instanceClient: OpencodeClient
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceFolder: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
const FilePicker: Component<FilePickerProps> = (props) => {
|
||||
@@ -36,10 +37,10 @@ const FilePicker: Component<FilePickerProps> = (props) => {
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`)
|
||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
||||
path,
|
||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
|
||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||
path: entry.path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
|
||||
297
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
297
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
let cachedEntries: FileSystemEntry[] | null = null
|
||||
let entriesPromise: Promise<FileSystemEntry[]> | null = null
|
||||
|
||||
async function loadFileSystemEntries(): Promise<FileSystemEntry[]> {
|
||||
if (cachedEntries) {
|
||||
return cachedEntries
|
||||
}
|
||||
if (entriesPromise) {
|
||||
return entriesPromise
|
||||
}
|
||||
entriesPromise = cliApi
|
||||
.listFileSystem(".")
|
||||
.then((entries) => {
|
||||
cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path))
|
||||
entriesPromise = null
|
||||
return cachedEntries
|
||||
})
|
||||
.catch((error) => {
|
||||
entriesPromise = null
|
||||
throw error
|
||||
})
|
||||
return entriesPromise
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
if (!relativePath || relativePath === "." || relativePath === "./") {
|
||||
return root
|
||||
}
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
|
||||
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function formatRootLabel(root: string): string {
|
||||
if (!root) return "Workspace Root"
|
||||
const parts = root.split(/[/\\]/).filter(Boolean)
|
||||
return parts[parts.length - 1] || root || "Workspace Root"
|
||||
}
|
||||
|
||||
interface FileSystemBrowserDialogProps {
|
||||
open: boolean
|
||||
mode: "directories" | "files"
|
||||
title: string
|
||||
description?: string
|
||||
onSelect: (absolutePath: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined
|
||||
|
||||
async function refreshEntries() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()])
|
||||
setEntries(items)
|
||||
setRootPath(meta.workspaceRoot)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const query = searchQuery().trim().toLowerCase()
|
||||
const mode = props.mode
|
||||
const root = rootPath()
|
||||
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
|
||||
|
||||
const baseEntries = mode === "directories" && root
|
||||
? [
|
||||
{
|
||||
name: formatRootLabel(root),
|
||||
path: ".",
|
||||
type: "directory" as const,
|
||||
},
|
||||
...matchesType,
|
||||
]
|
||||
: matchesType
|
||||
|
||||
if (!query) {
|
||||
return baseEntries
|
||||
}
|
||||
|
||||
return baseEntries.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(root, entry.path)
|
||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
||||
|
||||
createEffect(() => {
|
||||
const list = visibleEntries()
|
||||
if (list.length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
if (selectedIndex() >= list.length) {
|
||||
setSelectedIndex(list.length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
return
|
||||
}
|
||||
setSearchQuery("")
|
||||
setSelectedIndex(0)
|
||||
void refreshEntries()
|
||||
setTimeout(() => searchInputRef?.focus(), 50)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!props.open) return
|
||||
const results = visibleEntries()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
const entry = results[selectedIndex()]
|
||||
if (entry) {
|
||||
handleEntrySelect(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
|
||||
<div class="panel flex flex-col">
|
||||
<div class="panel-header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="panel-title">{props.title}</h3>
|
||||
<p class="panel-subtitle">
|
||||
{props.description || "Search for a path under the configured workspace root."}
|
||||
</p>
|
||||
<Show when={rootPath()}>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
<X class="w-4 h-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
|
||||
<div class="selector-input-group">
|
||||
<div class="flex items-center gap-2 px-3 text-muted">
|
||||
<Search class="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => {
|
||||
searchInputRef = el
|
||||
}}
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
|
||||
class="selector-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
||||
<Show
|
||||
when={!loading() && !error()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
||||
<Show
|
||||
when={loading()}
|
||||
fallback={<span class="text-red-500">{error()}</span>}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span>Loading filesystem…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||
<p>No matches.</p>
|
||||
<Show when={searchQuery().trim().length === 0}>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={visibleEntries()}>
|
||||
{(entry, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item flex items-center gap-3 text-left"
|
||||
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
onClick={() => handleEntrySelect(entry)}
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
|
||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||
<FolderIcon class="w-4 h-4" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
|
||||
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileSystemBrowserDialog
|
||||
@@ -2,12 +2,13 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect }
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder?: string, binaryPath?: string) => void
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
@@ -19,6 +20,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
const folders = () => recentFolders()
|
||||
@@ -173,12 +175,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
updateLastUsedBinary(selectedBinary())
|
||||
props.onSelectFolder(undefined, selectedBinary())
|
||||
setFocusMode("new")
|
||||
setIsFolderBrowserOpen(true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleBrowserSelect(path: string) {
|
||||
setIsFolderBrowserOpen(false)
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
@@ -378,6 +385,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<FileSystemBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
mode="directories"
|
||||
title="Browse for Folder"
|
||||
description="Select any directory exposed by the CLI server."
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
@@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [validationError, setValidationError] = createSignal<string | null>(null)
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
@@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
setValidating(true)
|
||||
setValidationError(null)
|
||||
|
||||
const result = await window.electronAPI.validateOpenCodeBinary(path)
|
||||
const result = await cliApi.validateBinary(path)
|
||||
|
||||
if (result.valid && result.version) {
|
||||
const updatedVersionInfo = new Map(versionInfo())
|
||||
@@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseBinary() {
|
||||
try {
|
||||
const path = await window.electronAPI.selectOpenCodeBinary()
|
||||
if (!path) return
|
||||
|
||||
setCustomPath(path)
|
||||
await handleValidateAndAdd(path)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
|
||||
}
|
||||
function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
setIsBinaryBrowserOpen(true)
|
||||
}
|
||||
|
||||
|
||||
async function handleValidateAndAdd(path: string) {
|
||||
const validation = await validateBinary(path)
|
||||
|
||||
@@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
setValidationError(validation.error || "Invalid OpenCode binary")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleBinaryBrowserSelect(path: string) {
|
||||
setIsBinaryBrowserOpen(false)
|
||||
setCustomPath(path)
|
||||
void handleValidateAndAdd(path)
|
||||
}
|
||||
|
||||
async function handleCustomPathSubmit() {
|
||||
|
||||
const path = customPath().trim()
|
||||
if (!path) return
|
||||
await handleValidateAndAdd(path)
|
||||
@@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const isPathValidating = (path: string) => validatingPaths().has(path)
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="panel-title">OpenCode Binary</h3>
|
||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
||||
</div>
|
||||
<Show when={validating()}>
|
||||
<div class="selector-loading text-xs">
|
||||
<Loader2 class="selector-loading-spinner" />
|
||||
<span>Checking versions…</span>
|
||||
<>
|
||||
<div class="panel">
|
||||
<div class="panel-header flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="panel-title">OpenCode Binary</h3>
|
||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
||||
</div>
|
||||
<Show when={validating()}>
|
||||
<div class="selector-loading text-xs">
|
||||
<Loader2 class="selector-loading-spinner" />
|
||||
<span>Checking versions…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-body space-y-3">
|
||||
<div class="selector-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath()}
|
||||
onInput={(e) => setCustomPath(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleCustomPathSubmit()
|
||||
}
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
placeholder="Enter path to opencode binary…"
|
||||
class="selector-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomPathSubmit}
|
||||
disabled={props.disabled || !customPath().trim()}
|
||||
class="selector-button selector-button-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-body space-y-3">
|
||||
<div class="selector-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath()}
|
||||
onInput={(e) => setCustomPath(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleCustomPathSubmit()
|
||||
}
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
placeholder="Enter path to opencode binary…"
|
||||
class="selector-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomPathSubmit}
|
||||
disabled={props.disabled || !customPath().trim()}
|
||||
class="selector-button selector-button-primary"
|
||||
onClick={handleBrowseBinary}
|
||||
disabled={props.disabled}
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Browse for Binary…
|
||||
</button>
|
||||
|
||||
<Show when={validationError()}>
|
||||
<div class="selector-validation-error">
|
||||
<div class="selector-validation-error-content">
|
||||
<AlertCircle class="selector-validation-error-icon" />
|
||||
<span class="selector-validation-error-text">{validationError()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseBinary}
|
||||
disabled={props.disabled}
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Browse for Binary…
|
||||
</button>
|
||||
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
|
||||
<For each={binaryOptions()}>
|
||||
{(binary) => {
|
||||
const isDefault = binary.isDefault
|
||||
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
|
||||
|
||||
<Show when={validationError()}>
|
||||
<div class="selector-validation-error">
|
||||
<div class="selector-validation-error-content">
|
||||
<AlertCircle class="selector-validation-error-icon" />
|
||||
<span class="selector-validation-error-text">{validationError()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
|
||||
<For each={binaryOptions()}>
|
||||
{(binary) => {
|
||||
const isDefault = binary.isDefault
|
||||
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
|
||||
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item flex items-center"
|
||||
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => handleSelectBinary(binary.path)}
|
||||
disabled={props.disabled}
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item flex items-center"
|
||||
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Check
|
||||
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
|
||||
</div>
|
||||
<Show when={!isDefault}>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||
<Show when={versionLabel()}>
|
||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
||||
</Show>
|
||||
<Show when={isPathValidating(binary.path)}>
|
||||
<span class="selector-badge-time">Checking…</span>
|
||||
</Show>
|
||||
<Show when={!isDefault && binary.lastUsed}>
|
||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||
</Show>
|
||||
<Show when={isDefault}>
|
||||
<span class="selector-badge-time">Use binary from system PATH</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={!isDefault}>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-muted hover:text-primary"
|
||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => handleSelectBinary(binary.path)}
|
||||
disabled={props.disabled}
|
||||
title="Remove binary"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Check
|
||||
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
|
||||
</div>
|
||||
<Show when={!isDefault}>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||
<Show when={versionLabel()}>
|
||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
||||
</Show>
|
||||
<Show when={isPathValidating(binary.path)}>
|
||||
<span class="selector-badge-time">Checking…</span>
|
||||
</Show>
|
||||
<Show when={!isDefault && binary.lastUsed}>
|
||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||
</Show>
|
||||
<Show when={isDefault}>
|
||||
<span class="selector-badge-time">Use binary from system PATH</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={!isDefault}>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-muted hover:text-primary"
|
||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||
disabled={props.disabled}
|
||||
title="Remove binary"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileSystemBrowserDialog
|
||||
open={isBinaryBrowserOpen()}
|
||||
mode="files"
|
||||
title="Select OpenCode Binary"
|
||||
description="Browse files exposed by the CLI server."
|
||||
onClose={() => setIsBinaryBrowserOpen(false)}
|
||||
onSelect={handleBinaryBrowserSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default OpenCodeBinarySelector
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [isFocused, setIsFocused] = createSignal(false)
|
||||
const [, setIsFocused] = createSignal(false)
|
||||
const [showPicker, setShowPicker] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||
@@ -744,7 +744,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceFolder={props.instanceFolder}
|
||||
workspaceId={props.instanceId}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
@@ -19,7 +20,7 @@ interface UnifiedPickerProps {
|
||||
instanceClient: OpencodeClient | null
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceFolder: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
@@ -38,9 +39,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
||||
path,
|
||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||
path: entry.path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
|
||||
143
packages/ui/src/lib/api-client.ts
Normal file
143
packages/ui/src/lib/api-client.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
AppConfigUpdateRequest,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
FileSystemEntry,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
WorkspaceLogEntry,
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
} from "../../../cli/src/api-types"
|
||||
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? "" : ""
|
||||
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...init, headers })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const cliApi = {
|
||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||
},
|
||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
deleteWorkspace(id: string): Promise<void> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
||||
},
|
||||
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<WorkspaceFileResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
},
|
||||
updateConfig(payload: AppConfig): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
listBinaries(): Promise<BinaryListResponse> {
|
||||
return request<BinaryListResponse>("/api/config/binaries")
|
||||
},
|
||||
createBinary(payload: BinaryCreateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
updateBinary(id: string, updates: BinaryUpdateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
},
|
||||
|
||||
deleteBinary(id: string): Promise<void> {
|
||||
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
validateBinary(path: string): Promise<BinaryValidationResult> {
|
||||
return request<BinaryValidationResult>("/api/config/binaries/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
},
|
||||
listFileSystem(relativePath = "."): Promise<FileSystemEntry[]> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
|
||||
},
|
||||
readInstanceData(id: string): Promise<InstanceData> {
|
||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||
},
|
||||
writeInstanceData(id: string, data: InstanceData): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
deleteInstanceData(id: string): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
onEvent(payload)
|
||||
} catch (error) {
|
||||
console.error("Failed to parse SSE event", error)
|
||||
}
|
||||
}
|
||||
source.onerror = () => {
|
||||
onError?.()
|
||||
}
|
||||
return source
|
||||
},
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
52
packages/ui/src/lib/cli-events.ts
Normal file
52
packages/ui/src/lib/cli-events.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
|
||||
const RETRY_BASE_DELAY = 1000
|
||||
const RETRY_MAX_DELAY = 10000
|
||||
|
||||
class CliEvents {
|
||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||
private source: EventSource | null = null
|
||||
private retryDelay = RETRY_BASE_DELAY
|
||||
|
||||
constructor() {
|
||||
this.connect()
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.source) {
|
||||
this.source.close()
|
||||
}
|
||||
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
||||
this.source.onopen = () => {
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.source) {
|
||||
this.source.close()
|
||||
this.source = null
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
|
||||
this.connect()
|
||||
}, this.retryDelay)
|
||||
}
|
||||
|
||||
private dispatch(event: WorkspaceEventPayload) {
|
||||
this.handlers.get("*")?.forEach((handler) => handler(event))
|
||||
this.handlers.get(event.type)?.forEach((handler) => handler(event))
|
||||
}
|
||||
|
||||
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, new Set())
|
||||
}
|
||||
const bucket = this.handlers.get(type)!
|
||||
bucket.add(handler)
|
||||
return () => bucket.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
export const cliEvents = new CliEvents()
|
||||
@@ -7,7 +7,6 @@ import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcut
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||
import { addLog, updateInstance } from "../../stores/instances"
|
||||
import type { Instance } from "../../types/instance"
|
||||
|
||||
interface UseAppLifecycleOptions {
|
||||
@@ -148,29 +147,6 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
window.electronAPI.onNewInstance(() => {
|
||||
options.handleNewInstanceRequest()
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
|
||||
console.log("Instance started:", { id, port, pid, binaryPath })
|
||||
updateInstance(id, { port, pid, status: "ready", binaryPath })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceError(({ id, error }) => {
|
||||
console.error("Instance error:", { id, error })
|
||||
updateInstance(id, { status: "error", error })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStopped(({ id }) => {
|
||||
console.log("Instance stopped:", id)
|
||||
updateInstance(id, { status: "stopped" })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceLog(({ id, entry }) => {
|
||||
addLog(id, entry)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
20
packages/ui/src/lib/server-meta.ts
Normal file
20
packages/ui/src/lib/server-meta.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ServerMeta } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
|
||||
let cachedMeta: ServerMeta | null = null
|
||||
let pendingMeta: Promise<ServerMeta> | null = null
|
||||
|
||||
export async function getServerMeta(): Promise<ServerMeta> {
|
||||
if (cachedMeta) {
|
||||
return cachedMeta
|
||||
}
|
||||
if (pendingMeta) {
|
||||
return pendingMeta
|
||||
}
|
||||
pendingMeta = cliApi.fetchServerMeta().then((meta) => {
|
||||
cachedMeta = meta
|
||||
pendingMeta = null
|
||||
return meta
|
||||
})
|
||||
return pendingMeta
|
||||
}
|
||||
@@ -1,162 +1,48 @@
|
||||
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
|
||||
import type { AppConfig, InstanceData } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
import { cliEvents } from "./cli-events"
|
||||
|
||||
export interface ConfigData {
|
||||
preferences: Preferences
|
||||
recentFolders: RecentFolder[]
|
||||
opencodeBinaries: OpenCodeBinary[]
|
||||
theme?: "light" | "dark" | "system"
|
||||
}
|
||||
export type ConfigData = AppConfig
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
}
|
||||
|
||||
export class FileStorage {
|
||||
private configPath: string | undefined
|
||||
private instancesDir: string | undefined
|
||||
export class ServerStorage {
|
||||
private configChangeListeners: Set<() => void> = new Set()
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
cliEvents.on("config.appChanged", () => this.notifyConfigChanged())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private parseConfig(content: string): ConfigData {
|
||||
const trimmed = content.trim()
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (error) {
|
||||
const firstBrace = trimmed.indexOf("{")
|
||||
const lastBrace = trimmed.lastIndexOf("}")
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sanitized = trimmed.slice(firstBrace, lastBrace + 1)
|
||||
|
||||
if (sanitized.length !== trimmed.length) {
|
||||
console.warn("Config file contained trailing data; attempting recovery")
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(sanitized)
|
||||
} catch {
|
||||
// Fall through to rethrow original error below
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Config operations
|
||||
async loadConfig(): Promise<ConfigData> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const content = await window.electronAPI.readConfigFile()
|
||||
return this.parseConfig(content)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load config, using defaults:", error)
|
||||
return {
|
||||
preferences: {
|
||||
showThinkingBlocks: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
},
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
}
|
||||
const config = await cliApi.fetchConfig()
|
||||
return config
|
||||
}
|
||||
|
||||
async saveConfig(config: ConfigData): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
throw error
|
||||
}
|
||||
await cliApi.updateConfig(config)
|
||||
}
|
||||
|
||||
// Instance operations
|
||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
const content = await window.electronAPI.readInstanceFile(filename)
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error)
|
||||
return {
|
||||
messageHistory: [],
|
||||
}
|
||||
}
|
||||
return cliApi.readInstanceData(instanceId)
|
||||
}
|
||||
|
||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
|
||||
} catch (error) {
|
||||
console.error(`Failed to save instance data for ${instanceId}:`, error)
|
||||
throw error
|
||||
}
|
||||
await cliApi.writeInstanceData(instanceId, data)
|
||||
}
|
||||
|
||||
async deleteInstanceData(instanceId: string): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
await window.electronAPI.deleteInstanceFile(filename)
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete instance data for ${instanceId}:`, error)
|
||||
throw error
|
||||
}
|
||||
await cliApi.deleteInstanceData(instanceId)
|
||||
}
|
||||
|
||||
// Convert folder path to safe filename
|
||||
private instanceIdToFilename(instanceId: string): string {
|
||||
// Convert folder path to safe filename
|
||||
// Replace path separators and other invalid characters
|
||||
return instanceId
|
||||
.replace(/[\\/]/g, "_") // Replace path separators
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars
|
||||
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
|
||||
.replace(/^_|_$/g, "") // Remove leading/trailing underscores
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// Config change listeners
|
||||
onConfigChanged(listener: () => void): () => void {
|
||||
this.configChangeListeners.add(listener)
|
||||
return () => this.configChangeListeners.delete(listener)
|
||||
}
|
||||
|
||||
private notifyConfigChanged() {
|
||||
for (const listener of this.configChangeListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const storage = new FileStorage()
|
||||
export const storage = new ServerStorage()
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { cliEvents } from "../lib/cli-events"
|
||||
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
@@ -35,6 +38,133 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
|
||||
const existing = instances().get(descriptor.id)
|
||||
return {
|
||||
id: descriptor.id,
|
||||
folder: descriptor.path,
|
||||
port: descriptor.port ?? existing?.port ?? 0,
|
||||
pid: descriptor.pid ?? existing?.pid ?? 0,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
client: existing?.client ?? null,
|
||||
metadata: existing?.metadata,
|
||||
binaryPath: descriptor.binaryLabel,
|
||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
const mapped = workspaceDescriptorToInstance(descriptor)
|
||||
if (instances().has(descriptor.id)) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready" && descriptor.port) {
|
||||
attachClient(descriptor.id, descriptor.port)
|
||||
}
|
||||
}
|
||||
|
||||
function attachClient(instanceId: string, port: number) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port === port && instance.client) {
|
||||
return
|
||||
}
|
||||
|
||||
if (instance.port && instance.client) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
sseManager.disconnect(instanceId)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
updateInstance(instanceId, {
|
||||
client,
|
||||
port,
|
||||
status: "ready",
|
||||
})
|
||||
sseManager.connect(instanceId, port)
|
||||
void hydrateInstanceData(instanceId).catch((error) => {
|
||||
console.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseInstanceResources(instanceId: string) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
sseManager.disconnect(instanceId)
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await cliApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workspaces", error)
|
||||
}
|
||||
})()
|
||||
|
||||
cliEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
|
||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
switch (event.type) {
|
||||
case "workspace.created":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.started":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceLog(entry: WorkspaceLogEntry) {
|
||||
const logEntry: LogEntry = {
|
||||
timestamp: new Date(entry.timestamp).getTime(),
|
||||
level: (entry.level as LogEntry["level"]) ?? "info",
|
||||
message: entry.message,
|
||||
}
|
||||
addLog(entry.workspaceId, logEntry)
|
||||
}
|
||||
|
||||
function ensureLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (prev.has(id)) {
|
||||
@@ -157,61 +287,17 @@ function removeInstance(id: string) {
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
client: null,
|
||||
environmentVariables: preferences().environmentVariables ?? {},
|
||||
}
|
||||
|
||||
addInstance(instance)
|
||||
|
||||
// Update last used binary
|
||||
if (binaryPath) {
|
||||
updateLastUsedBinary(binaryPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
id: returnedId,
|
||||
port,
|
||||
pid,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables)
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
updateInstance(id, {
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
binaryPath: actualBinaryPath,
|
||||
})
|
||||
|
||||
setActiveInstanceId(id)
|
||||
sseManager.connect(id, port)
|
||||
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
await fetchCommands(id, client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
|
||||
return id
|
||||
const workspace = await cliApi.createWorkspace({ path: folder })
|
||||
upsertWorkspace(workspace)
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
} catch (error) {
|
||||
updateInstance(id, {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
console.error("Failed to create workspace", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -220,17 +306,18 @@ async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
sseManager.disconnect(id)
|
||||
releaseInstanceResources(id)
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.stopInstance(instance.pid)
|
||||
try {
|
||||
await cliApi.deleteWorkspace(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop workspace", error)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { storage, type InstanceData } from "../lib/storage"
|
||||
import { storage } from "../lib/storage"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
@@ -48,7 +48,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const data = await storage.loadInstanceData(instanceId)
|
||||
instanceHistories.set(instanceId, data.messageHistory)
|
||||
const history = Array.isArray(data.messageHistory) ? data.messageHistory : []
|
||||
instanceHistories.set(instanceId, history)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
|
||||
@@ -17,12 +17,12 @@ export type ExpansionPreference = "expanded" | "collapsed"
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables?: Record<string, string>
|
||||
modelRecents?: ModelPreference[]
|
||||
agentModelSelections?: AgentModelSelections
|
||||
diffViewMode?: DiffViewMode
|
||||
toolOutputExpansion?: ExpansionPreference
|
||||
diagnosticsExpansion?: ExpansionPreference
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
agentModelSelections: AgentModelSelections
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
@@ -41,6 +41,7 @@ const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
@@ -48,12 +49,41 @@ const defaultPreferences: Preferences = {
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||
function normalizePreferences(pref?: Partial<Preferences>): Preferences {
|
||||
const environmentVariables = {
|
||||
...defaultPreferences.environmentVariables,
|
||||
...(pref?.environmentVariables ?? {}),
|
||||
}
|
||||
|
||||
const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections
|
||||
const agentModelSelections: AgentModelSelections = {}
|
||||
for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) {
|
||||
agentModelSelections[instanceId] = Object.fromEntries(
|
||||
Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
agentModelSelections,
|
||||
diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
}
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(normalizePreferences())
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: defaultPreferences,
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
@@ -64,15 +94,15 @@ async function loadConfig(): Promise<void> {
|
||||
const config = await storage.loadConfig()
|
||||
cachedConfig = {
|
||||
...config,
|
||||
preferences: { ...defaultPreferences, ...config.preferences },
|
||||
recentFolders: config.recentFolders || [],
|
||||
opencodeBinaries: config.opencodeBinaries || [],
|
||||
preferences: normalizePreferences(config.preferences),
|
||||
recentFolders: config.recentFolders ?? [],
|
||||
opencodeBinaries: config.opencodeBinaries ?? [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: { ...defaultPreferences },
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
@@ -112,7 +142,7 @@ async function ensureConfigLoaded(): Promise<void> {
|
||||
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = { ...preferences(), ...updates }
|
||||
const updated = normalizePreferences({ ...preferences(), ...updates })
|
||||
setPreferences(updated)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface ElectronAPI {
|
||||
selectFolder: () => Promise<string | null>
|
||||
createInstance: (
|
||||
id: string,
|
||||
folder: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
|
||||
stopInstance: (pid: number) => Promise<void>
|
||||
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
|
||||
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
|
||||
onInstanceStopped: (callback: (data: { id: string }) => void) => void
|
||||
onInstanceLog: (
|
||||
callback: (data: {
|
||||
id: string
|
||||
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
|
||||
}) => void,
|
||||
) => void
|
||||
onNewInstance: (callback: () => void) => void
|
||||
scanDirectory: (workspaceFolder: string) => Promise<string[]>
|
||||
selectOpenCodeBinary: () => Promise<string | null>
|
||||
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
|
||||
getConfigPath: () => Promise<string>
|
||||
getInstancesDir: () => Promise<string>
|
||||
readConfigFile: () => Promise<string>
|
||||
writeConfigFile: (content: string) => Promise<void>
|
||||
readInstanceFile: (instanceId: string) => Promise<string>
|
||||
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
|
||||
deleteInstanceFile: (instanceId: string) => Promise<void>
|
||||
onConfigChanged: (callback: () => void) => () => void
|
||||
}
|
||||
9
packages/ui/src/types/electron.d.ts
vendored
9
packages/ui/src/types/electron.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
import type { ElectronAPI } from "./electron-api"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
8
packages/ui/src/types/global.d.ts
vendored
Normal file
8
packages/ui/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
}
|
||||
}
|
||||
1
packages/ui/src/vite-env.d.ts
vendored
Normal file
1
packages/ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user