Add CLI server and move UI to HTTP API
This commit is contained in:
886
package-lock.json
generated
886
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
packages/cli/package.json
Normal file
22
packages/cli/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@codenomad/cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "CodeNomad CLI server for HTTP/SSE control plane",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
153
packages/cli/src/api-types.ts
Normal file
153
packages/cli/src/api-types.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type {
|
||||||
|
AgentModelSelections,
|
||||||
|
ConfigFile,
|
||||||
|
ModelPreference,
|
||||||
|
OpenCodeBinary,
|
||||||
|
Preferences,
|
||||||
|
RecentFolder,
|
||||||
|
} from "./config/schema"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical HTTP/SSE contract for the CLI server.
|
||||||
|
* These types are consumed by both the CLI implementation and any UI clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||||
|
|
||||||
|
export interface WorkspaceDescriptor {
|
||||||
|
id: string
|
||||||
|
/** Absolute path on the server host. */
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
|
status: WorkspaceStatus
|
||||||
|
/** PID/port are populated when the workspace is running. */
|
||||||
|
pid?: number
|
||||||
|
port?: number
|
||||||
|
/** Identifier of the binary resolved from config. */
|
||||||
|
binaryId: string
|
||||||
|
binaryLabel: string
|
||||||
|
binaryVersion?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
/** Present when `status` is "error". */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceCreateRequest {
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||||
|
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||||
|
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||||
|
|
||||||
|
export interface WorkspaceDeleteResponse {
|
||||||
|
id: string
|
||||||
|
status: WorkspaceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
|
export interface WorkspaceLogEntry {
|
||||||
|
workspaceId: string
|
||||||
|
timestamp: string
|
||||||
|
level: LogLevel
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemEntry {
|
||||||
|
name: string
|
||||||
|
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||||
|
path: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
size?: number
|
||||||
|
/** ISO timestamp of last modification when available. */
|
||||||
|
modifiedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileSystemListResponse = FileSystemEntry[]
|
||||||
|
|
||||||
|
export interface WorkspaceFileResponse {
|
||||||
|
workspaceId: string
|
||||||
|
relativePath: string
|
||||||
|
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
|
||||||
|
contents: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceData {
|
||||||
|
messageHistory: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryRecord {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
version?: string
|
||||||
|
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||||
|
isDefault: boolean
|
||||||
|
lastValidatedAt?: string
|
||||||
|
validationError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppConfig = ConfigFile
|
||||||
|
export type AppConfigResponse = AppConfig
|
||||||
|
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||||
|
|
||||||
|
export interface BinaryListResponse {
|
||||||
|
binaries: BinaryRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryCreateRequest {
|
||||||
|
path: string
|
||||||
|
label?: string
|
||||||
|
makeDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryUpdateRequest {
|
||||||
|
label?: string
|
||||||
|
makeDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
version?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceEventType =
|
||||||
|
| "workspace.created"
|
||||||
|
| "workspace.started"
|
||||||
|
| "workspace.error"
|
||||||
|
| "workspace.stopped"
|
||||||
|
| "workspace.log"
|
||||||
|
| "config.appChanged"
|
||||||
|
| "config.binariesChanged"
|
||||||
|
|
||||||
|
export type WorkspaceEventPayload =
|
||||||
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
|
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||||
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
|
| { type: "config.appChanged"; config: AppConfig }
|
||||||
|
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||||
|
|
||||||
|
export interface ServerMeta {
|
||||||
|
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||||
|
httpBaseUrl: string
|
||||||
|
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||||
|
eventsUrl: string
|
||||||
|
/** Display label for the host (e.g., hostname or friendly name). */
|
||||||
|
hostLabel: string
|
||||||
|
/** Absolute path of the filesystem root exposed to clients. */
|
||||||
|
workspaceRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Preferences,
|
||||||
|
ModelPreference,
|
||||||
|
AgentModelSelections,
|
||||||
|
RecentFolder,
|
||||||
|
OpenCodeBinary,
|
||||||
|
}
|
||||||
144
packages/cli/src/config/binaries.ts
Normal file
144
packages/cli/src/config/binaries.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
BinaryCreateRequest,
|
||||||
|
BinaryRecord,
|
||||||
|
BinaryUpdateRequest,
|
||||||
|
BinaryValidationResult,
|
||||||
|
} from "../api-types"
|
||||||
|
import { ConfigStore } from "./store"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigFileUpdate } from "./schema"
|
||||||
|
|
||||||
|
export class BinaryRegistry {
|
||||||
|
constructor(private readonly configStore: ConfigStore, private readonly eventBus?: EventBus) {}
|
||||||
|
|
||||||
|
list(): BinaryRecord[] {
|
||||||
|
return this.mapRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefault(): BinaryRecord {
|
||||||
|
const binaries = this.mapRecords()
|
||||||
|
if (binaries.length === 0) {
|
||||||
|
return this.buildFallbackRecord("opencode")
|
||||||
|
}
|
||||||
|
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
create(request: BinaryCreateRequest): BinaryRecord {
|
||||||
|
const entry = {
|
||||||
|
path: request.path,
|
||||||
|
version: undefined,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
label: request.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||||
|
|
||||||
|
const update: ConfigFileUpdate = {
|
||||||
|
opencodeBinaries: [entry, ...deduped],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.makeDefault) {
|
||||||
|
update.preferences = { lastUsedBinary: request.path }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.update(update)
|
||||||
|
const record = this.getById(request.path)
|
||||||
|
this.emitChange()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
||||||
|
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
const update: ConfigFileUpdate = {
|
||||||
|
opencodeBinaries: updatedEntries,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.makeDefault) {
|
||||||
|
update.preferences = { lastUsedBinary: id }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.update(update)
|
||||||
|
const record = this.getById(id)
|
||||||
|
this.emitChange()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||||
|
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
||||||
|
|
||||||
|
if (config.preferences.lastUsedBinary === id) {
|
||||||
|
update.preferences = { lastUsedBinary: remaining[0]?.path }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.update(update)
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePath(path: string): BinaryValidationResult {
|
||||||
|
return this.validateRecord({
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
label: this.prettyLabel(path),
|
||||||
|
isDefault: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRecords(): BinaryRecord[] {
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||||
|
id: binary.path,
|
||||||
|
path: binary.path,
|
||||||
|
label: binary.label ?? this.prettyLabel(binary.path),
|
||||||
|
version: binary.version,
|
||||||
|
isDefault: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||||
|
|
||||||
|
const annotated = configuredBinaries.map((binary) => ({
|
||||||
|
...binary,
|
||||||
|
isDefault: binary.path === defaultPath,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||||
|
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotated
|
||||||
|
}
|
||||||
|
|
||||||
|
private getById(id: string): BinaryRecord {
|
||||||
|
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange() {
|
||||||
|
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||||
|
// TODO: call actual binary -v check.
|
||||||
|
return { valid: true, version: record.version }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFallbackRecord(path: string): BinaryRecord {
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
label: this.prettyLabel(path),
|
||||||
|
isDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prettyLabel(path: string) {
|
||||||
|
const parts = path.split(/[\\/]/)
|
||||||
|
const last = parts[parts.length - 1] || path
|
||||||
|
return last || path
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/cli/src/config/schema.ts
Normal file
80
packages/cli/src/config/schema.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const ModelPreferenceSchema = z.object({
|
||||||
|
providerId: z.string(),
|
||||||
|
modelId: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||||
|
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||||
|
|
||||||
|
const PreferencesSchema = z.object({
|
||||||
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
|
lastUsedBinary: z.string().optional(),
|
||||||
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
agentModelSelections: AgentModelSelectionsSchema.default({}),
|
||||||
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PreferencesUpdateSchema = z.object({
|
||||||
|
showThinkingBlocks: z.boolean().optional(),
|
||||||
|
lastUsedBinary: z.string().optional(),
|
||||||
|
environmentVariables: z.record(z.string()).optional(),
|
||||||
|
modelRecents: z.array(ModelPreferenceSchema).optional(),
|
||||||
|
agentModelSelections: AgentModelSelectionsSchema.optional(),
|
||||||
|
diffViewMode: z.enum(["split", "unified"]).optional(),
|
||||||
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||||
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RecentFolderSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
lastAccessed: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const OpenCodeBinarySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
version: z.string().optional(),
|
||||||
|
lastUsed: z.number().nonnegative(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ConfigFileSchema = z.object({
|
||||||
|
preferences: PreferencesSchema.default({}),
|
||||||
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ConfigFileUpdateSchema = z.object({
|
||||||
|
preferences: PreferencesUpdateSchema.optional(),
|
||||||
|
recentFolders: z.array(RecentFolderSchema).optional(),
|
||||||
|
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
|
||||||
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||||
|
|
||||||
|
export {
|
||||||
|
ModelPreferenceSchema,
|
||||||
|
AgentModelSelectionSchema,
|
||||||
|
AgentModelSelectionsSchema,
|
||||||
|
PreferencesSchema,
|
||||||
|
RecentFolderSchema,
|
||||||
|
OpenCodeBinarySchema,
|
||||||
|
ConfigFileSchema,
|
||||||
|
ConfigFileUpdateSchema,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||||
|
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
|
||||||
|
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
|
||||||
|
export type Preferences = z.infer<typeof PreferencesSchema>
|
||||||
|
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||||
|
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||||
|
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||||
|
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>
|
||||||
111
packages/cli/src/config/store.ts
Normal file
111
packages/cli/src/config/store.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import {
|
||||||
|
AgentModelSelections,
|
||||||
|
ConfigFile,
|
||||||
|
ConfigFileUpdate,
|
||||||
|
ConfigFileSchema,
|
||||||
|
ConfigFileUpdateSchema,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
export class ConfigStore {
|
||||||
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(private readonly configPath: string, private readonly eventBus?: EventBus) {}
|
||||||
|
|
||||||
|
load(): ConfigFile {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = this.resolvePath(this.configPath)
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
const content = fs.readFileSync(resolved, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
this.cache = ConfigFileSchema.parse(parsed)
|
||||||
|
} else {
|
||||||
|
this.cache = DEFAULT_CONFIG
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load config", error)
|
||||||
|
this.cache = DEFAULT_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): ConfigFile {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(partial: ConfigFile | ConfigFileUpdate) {
|
||||||
|
const safePartial =
|
||||||
|
"recentFolders" in partial && "opencodeBinaries" in partial
|
||||||
|
? ConfigFileSchema.parse(partial)
|
||||||
|
: ConfigFileUpdateSchema.parse(partial ?? {})
|
||||||
|
const merged = this.mergeConfig(this.load(), safePartial)
|
||||||
|
this.cache = ConfigFileSchema.parse(merged)
|
||||||
|
this.persist()
|
||||||
|
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
||||||
|
const mergedPreferences = {
|
||||||
|
...current.preferences,
|
||||||
|
...partial.preferences,
|
||||||
|
environmentVariables: {
|
||||||
|
...current.preferences.environmentVariables,
|
||||||
|
...(partial.preferences?.environmentVariables ?? {}),
|
||||||
|
},
|
||||||
|
agentModelSelections: this.mergeAgentSelections(
|
||||||
|
current.preferences.agentModelSelections,
|
||||||
|
partial.preferences?.agentModelSelections,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...partial,
|
||||||
|
preferences: mergedPreferences,
|
||||||
|
recentFolders: partial.recentFolders ?? current.recentFolders,
|
||||||
|
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
|
||||||
|
if (!update) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AgentModelSelections = { ...base }
|
||||||
|
for (const [instanceId, agentMap] of Object.entries(update)) {
|
||||||
|
result[instanceId] = {
|
||||||
|
...(base[instanceId] ?? {}),
|
||||||
|
...agentMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
const resolved = this.resolvePath(this.configPath)
|
||||||
|
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||||
|
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to persist config", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePath(filePath: string) {
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/cli/src/events/bus.ts
Normal file
28
packages/cli/src/events/bus.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { EventEmitter } from "events"
|
||||||
|
import { WorkspaceEventPayload } from "../api-types"
|
||||||
|
|
||||||
|
export class EventBus extends EventEmitter {
|
||||||
|
publish(event: WorkspaceEventPayload): boolean {
|
||||||
|
return super.emit(event.type, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(listener: (event: WorkspaceEventPayload) => void) {
|
||||||
|
const handler = (event: WorkspaceEventPayload) => listener(event)
|
||||||
|
this.on("workspace.created", handler)
|
||||||
|
this.on("workspace.started", handler)
|
||||||
|
this.on("workspace.error", handler)
|
||||||
|
this.on("workspace.stopped", handler)
|
||||||
|
this.on("workspace.log", handler)
|
||||||
|
this.on("config.appChanged", handler)
|
||||||
|
this.on("config.binariesChanged", handler)
|
||||||
|
return () => {
|
||||||
|
this.off("workspace.created", handler)
|
||||||
|
this.off("workspace.started", handler)
|
||||||
|
this.off("workspace.error", handler)
|
||||||
|
this.off("workspace.stopped", handler)
|
||||||
|
this.off("workspace.log", handler)
|
||||||
|
this.off("config.appChanged", handler)
|
||||||
|
this.off("config.binariesChanged", handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/cli/src/filesystem/browser.ts
Normal file
54
packages/cli/src/filesystem/browser.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { FileSystemEntry } from "../api-types"
|
||||||
|
|
||||||
|
interface FileSystemBrowserOptions {
|
||||||
|
rootDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileSystemBrowser {
|
||||||
|
private readonly root: string
|
||||||
|
|
||||||
|
constructor(options: FileSystemBrowserOptions) {
|
||||||
|
this.root = path.resolve(options.rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
list(relativePath: string): FileSystemEntry[] {
|
||||||
|
const resolved = this.toAbsolute(relativePath)
|
||||||
|
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
||||||
|
|
||||||
|
return entries.flatMap<FileSystemEntry>((entry) => {
|
||||||
|
const entryPath = path.join(relativePath, entry.name)
|
||||||
|
const absolutePath = this.toAbsolute(entryPath)
|
||||||
|
const stats = fs.statSync(absolutePath)
|
||||||
|
|
||||||
|
const current: FileSystemEntry = {
|
||||||
|
name: entry.name,
|
||||||
|
path: entryPath,
|
||||||
|
type: entry.isDirectory() ? "directory" : "file",
|
||||||
|
size: entry.isDirectory() ? undefined : stats.size,
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = this.list(entryPath)
|
||||||
|
return [current, ...nested]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [current]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile(relativePath: string): string {
|
||||||
|
const resolved = this.toAbsolute(relativePath)
|
||||||
|
return fs.readFileSync(resolved, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAbsolute(relativePath: string) {
|
||||||
|
const target = path.resolve(this.root, relativePath)
|
||||||
|
if (!target.startsWith(this.root)) {
|
||||||
|
throw new Error("Access outside of root is not allowed")
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
89
packages/cli/src/index.ts
Normal file
89
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* CLI entry point.
|
||||||
|
* For now this only wires the typed modules together; actual command handling comes later.
|
||||||
|
*/
|
||||||
|
import { createHttpServer } from "./server/http-server"
|
||||||
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
|
import { ConfigStore } from "./config/store"
|
||||||
|
import { BinaryRegistry } from "./config/binaries"
|
||||||
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
|
import { EventBus } from "./events/bus"
|
||||||
|
import { ServerMeta } from "./api-types"
|
||||||
|
import { InstanceStore } from "./storage/instance-store"
|
||||||
|
|
||||||
|
interface CliOptions {
|
||||||
|
port: number
|
||||||
|
host: string
|
||||||
|
rootDir: string
|
||||||
|
configPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
|
// TODO: replace with commander/yargs; this is placeholder logic.
|
||||||
|
const args = new Map<string, string>()
|
||||||
|
for (let i = 0; i < argv.length; i += 2) {
|
||||||
|
const key = argv[i]
|
||||||
|
const value = argv[i + 1]
|
||||||
|
if (key && key.startsWith("--") && value) {
|
||||||
|
args.set(key.slice(2), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(args.get("port") ?? process.env.CLI_PORT ?? 5777),
|
||||||
|
host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1",
|
||||||
|
rootDir: args.get("root") ?? process.cwd(),
|
||||||
|
configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseCliOptions(process.argv.slice(2))
|
||||||
|
|
||||||
|
const eventBus = new EventBus()
|
||||||
|
const configStore = new ConfigStore(options.configPath, eventBus)
|
||||||
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus)
|
||||||
|
const workspaceManager = new WorkspaceManager({
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
|
eventBus,
|
||||||
|
})
|
||||||
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
|
||||||
|
const instanceStore = new InstanceStore()
|
||||||
|
|
||||||
|
const serverMeta: ServerMeta = {
|
||||||
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
|
eventsUrl: `/api/events`,
|
||||||
|
hostLabel: options.host,
|
||||||
|
workspaceRoot: options.rootDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createHttpServer({
|
||||||
|
host: options.host,
|
||||||
|
port: options.port,
|
||||||
|
workspaceManager,
|
||||||
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
|
fileSystemBrowser,
|
||||||
|
eventBus,
|
||||||
|
serverMeta,
|
||||||
|
instanceStore,
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
await server.stop()
|
||||||
|
await workspaceManager.shutdown()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown)
|
||||||
|
process.on("SIGTERM", shutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("CLI server crashed", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
49
packages/cli/src/server/http-server.ts
Normal file
49
packages/cli/src/server/http-server.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Fastify from "fastify"
|
||||||
|
import cors from "@fastify/cors"
|
||||||
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
|
import { ConfigStore } from "../config/store"
|
||||||
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
|
import { registerConfigRoutes } from "./routes/config"
|
||||||
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
|
import { registerEventRoutes } from "./routes/events"
|
||||||
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
|
import { ServerMeta } from "../api-types"
|
||||||
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
|
|
||||||
|
interface HttpServerDeps {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
|
fileSystemBrowser: FileSystemBrowser
|
||||||
|
eventBus: EventBus
|
||||||
|
serverMeta: ServerMeta
|
||||||
|
instanceStore: InstanceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpServer(deps: HttpServerDeps) {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
|
||||||
|
app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
|
registerEventRoutes(app, { eventBus: deps.eventBus })
|
||||||
|
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||||
|
|
||||||
|
return {
|
||||||
|
instance: app,
|
||||||
|
start: () => app.listen({ port: deps.port, host: deps.host }),
|
||||||
|
stop: () => app.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/cli/src/server/routes/config.ts
Normal file
68
packages/cli/src/server/routes/config.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { ConfigStore } from "../../config/store"
|
||||||
|
import { BinaryRegistry } from "../../config/binaries"
|
||||||
|
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
const BinaryCreateSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
makeDefault: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const BinaryUpdateSchema = z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
makeDefault: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const BinaryValidateSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/config/app", async () => deps.configStore.get())
|
||||||
|
|
||||||
|
app.put("/api/config/app", async (request) => {
|
||||||
|
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||||
|
deps.configStore.update(body)
|
||||||
|
return deps.configStore.get()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/config/app", async (request) => {
|
||||||
|
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
|
||||||
|
deps.configStore.update(body)
|
||||||
|
return deps.configStore.get()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/config/binaries", async () => {
|
||||||
|
return { binaries: deps.binaryRegistry.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/config/binaries", async (request, reply) => {
|
||||||
|
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||||
|
const binary = deps.binaryRegistry.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return { binary }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||||
|
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||||
|
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||||
|
return { binary }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||||
|
deps.binaryRegistry.remove(request.params.id)
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/config/binaries/validate", async (request) => {
|
||||||
|
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||||
|
return deps.binaryRegistry.validatePath(body.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
37
packages/cli/src/server/routes/events.ts
Normal file
37
packages/cli/src/server/routes/events.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { EventBus } from "../../events/bus"
|
||||||
|
import { WorkspaceEventPayload } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
eventBus: EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/events", (request, reply) => {
|
||||||
|
const origin = request.headers.origin ?? "*"
|
||||||
|
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||||
|
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||||
|
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||||
|
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||||
|
reply.raw.setHeader("Connection", "keep-alive")
|
||||||
|
reply.raw.flushHeaders?.()
|
||||||
|
reply.hijack()
|
||||||
|
|
||||||
|
const send = (event: WorkspaceEventPayload) => {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = deps.eventBus.onEvent(send)
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
request.raw.on("close", close)
|
||||||
|
request.raw.on("error", close)
|
||||||
|
})
|
||||||
|
}
|
||||||
25
packages/cli/src/server/routes/filesystem.ts
Normal file
25
packages/cli/src/server/routes/filesystem.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { FileSystemBrowser } from "../../filesystem/browser"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
fileSystemBrowser: FileSystemBrowser
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilesystemQuerySchema = z.object({
|
||||||
|
path: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/filesystem", async (request, reply) => {
|
||||||
|
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||||
|
const targetPath = query.path ?? "."
|
||||||
|
|
||||||
|
try {
|
||||||
|
return deps.fileSystemBrowser.list(targetPath)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
10
packages/cli/src/server/routes/meta.ts
Normal file
10
packages/cli/src/server/routes/meta.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { ServerMeta } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
serverMeta: ServerMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/meta", async () => deps.serverMeta)
|
||||||
|
}
|
||||||
44
packages/cli/src/server/routes/storage.ts
Normal file
44
packages/cli/src/server/routes/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { InstanceStore } from "../../storage/instance-store"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
instanceStore: InstanceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceDataSchema = z.object({
|
||||||
|
messageHistory: z.array(z.string()).default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const data = await deps.instanceStore.read(request.params.id)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(500)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||||
|
await deps.instanceStore.write(request.params.id, body)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await deps.instanceStore.delete(request.params.id)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(500)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
packages/cli/src/server/routes/workspaces.ts
Normal file
80
packages/cli/src/server/routes/workspaces.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { FastifyInstance, FastifyReply } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceCreateSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorkspaceFilesQuerySchema = z.object({
|
||||||
|
path: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorkspaceFileContentQuerySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/workspaces", async () => {
|
||||||
|
return deps.workspaceManager.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/workspaces", async (request, reply) => {
|
||||||
|
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||||
|
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||||
|
reply.code(201)
|
||||||
|
return workspace
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
return workspace
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||||
|
await deps.workspaceManager.delete(request.params.id)
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string }
|
||||||
|
Querystring: { path?: string }
|
||||||
|
}>("/api/workspaces/:id/files", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
|
||||||
|
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string }
|
||||||
|
Querystring: { path?: string }
|
||||||
|
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||||
|
return deps.workspaceManager.readFile(request.params.id, query.path)
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||||
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Workspace not found" }
|
||||||
|
}
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||||
|
}
|
||||||
63
packages/cli/src/storage/instance-store.ts
Normal file
63
packages/cli/src/storage/instance-store.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import type { InstanceData } from "../api-types"
|
||||||
|
|
||||||
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
|
messageHistory: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InstanceStore {
|
||||||
|
private readonly instancesDir: string
|
||||||
|
|
||||||
|
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
|
||||||
|
this.instancesDir = baseDir
|
||||||
|
fs.mkdirSync(this.instancesDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(id: string): Promise<InstanceData> {
|
||||||
|
try {
|
||||||
|
const filePath = this.resolvePath(id)
|
||||||
|
const content = await fsp.readFile(filePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return DEFAULT_INSTANCE_DATA
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(id: string, data: InstanceData): Promise<void> {
|
||||||
|
const filePath = this.resolvePath(id)
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filePath = this.resolvePath(id)
|
||||||
|
await fsp.unlink(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePath(id: string): string {
|
||||||
|
const filename = this.sanitizeId(id)
|
||||||
|
return path.join(this.instancesDir, `${filename}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeId(id: string): string {
|
||||||
|
return id
|
||||||
|
.replace(/[\\/]/g, "_")
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, "_")
|
||||||
|
.replace(/_{2,}/g, "_")
|
||||||
|
.replace(/^_|_$/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/cli/src/workspaces/manager.ts
Normal file
148
packages/cli/src/workspaces/manager.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { ConfigStore } from "../config/store"
|
||||||
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
|
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
|
import { WorkspaceRuntime } from "./runtime"
|
||||||
|
|
||||||
|
interface WorkspaceManagerOptions {
|
||||||
|
rootDir: string
|
||||||
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
|
eventBus: EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||||
|
|
||||||
|
export class WorkspaceManager {
|
||||||
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||||
|
private readonly runtime: WorkspaceRuntime
|
||||||
|
|
||||||
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
|
this.runtime = new WorkspaceRuntime(this.options.eventBus)
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): WorkspaceDescriptor[] {
|
||||||
|
return Array.from(this.workspaces.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): WorkspaceDescriptor | undefined {
|
||||||
|
return this.workspaces.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||||
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
|
return browser.list(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||||
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
|
const contents = browser.readFile(relativePath)
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
relativePath,
|
||||||
|
contents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
const id = `${Date.now().toString(36)}`
|
||||||
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
|
|
||||||
|
const descriptor: WorkspaceRecord = {
|
||||||
|
id,
|
||||||
|
path: workspacePath,
|
||||||
|
name,
|
||||||
|
status: "starting",
|
||||||
|
binaryId: binary.id,
|
||||||
|
binaryLabel: binary.label,
|
||||||
|
binaryVersion: binary.version,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workspaces.set(id, descriptor)
|
||||||
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
|
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pid, port } = await this.runtime.launch({
|
||||||
|
workspaceId: id,
|
||||||
|
folder: workspacePath,
|
||||||
|
binaryPath: binary.path,
|
||||||
|
environment,
|
||||||
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
|
})
|
||||||
|
|
||||||
|
descriptor.pid = pid
|
||||||
|
descriptor.port = port
|
||||||
|
descriptor.status = "ready"
|
||||||
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
|
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||||
|
return descriptor
|
||||||
|
} catch (error) {
|
||||||
|
descriptor.status = "error"
|
||||||
|
descriptor.error = error instanceof Error ? error.message : String(error)
|
||||||
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
|
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
|
||||||
|
const workspace = this.workspaces.get(id)
|
||||||
|
if (!workspace) return undefined
|
||||||
|
|
||||||
|
const wasRunning = Boolean(workspace.pid)
|
||||||
|
if (wasRunning) {
|
||||||
|
await this.runtime.stop(id).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workspaces.delete(id)
|
||||||
|
if (!wasRunning) {
|
||||||
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
|
}
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
for (const [id] of this.workspaces) {
|
||||||
|
if (this.workspaces.get(id)?.pid) {
|
||||||
|
await this.runtime.stop(id).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.workspaces.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireWorkspace(id: string): WorkspaceRecord {
|
||||||
|
const workspace = this.workspaces.get(id)
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error("Workspace not found")
|
||||||
|
}
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||||
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
|
if (!workspace) return
|
||||||
|
|
||||||
|
workspace.pid = undefined
|
||||||
|
workspace.port = undefined
|
||||||
|
workspace.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
if (info.requested || info.code === 0) {
|
||||||
|
workspace.status = "stopped"
|
||||||
|
workspace.error = undefined
|
||||||
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
|
||||||
|
} else {
|
||||||
|
workspace.status = "error"
|
||||||
|
workspace.error = `Process exited with code ${info.code}`
|
||||||
|
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
packages/cli/src/workspaces/runtime.ts
Normal file
180
packages/cli/src/workspaces/runtime.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { ChildProcess, spawn } from "child_process"
|
||||||
|
import { existsSync, statSync } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
|
|
||||||
|
interface LaunchOptions {
|
||||||
|
workspaceId: string
|
||||||
|
folder: string
|
||||||
|
binaryPath: string
|
||||||
|
environment?: Record<string, string>
|
||||||
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessExitInfo {
|
||||||
|
workspaceId: string
|
||||||
|
code: number | null
|
||||||
|
signal: NodeJS.Signals | null
|
||||||
|
requested: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedProcess {
|
||||||
|
child: ChildProcess
|
||||||
|
requestedStop: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceRuntime {
|
||||||
|
private processes = new Map<string, ManagedProcess>()
|
||||||
|
|
||||||
|
constructor(private readonly eventBus: EventBus) {}
|
||||||
|
|
||||||
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||||
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||||
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(options.binaryPath, args, {
|
||||||
|
cwd: options.folder,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const managed: ManagedProcess = { child, requestedStop: false }
|
||||||
|
this.processes.set(options.workspaceId, managed)
|
||||||
|
|
||||||
|
let stdoutBuffer = ""
|
||||||
|
let stderrBuffer = ""
|
||||||
|
let portFound = false
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL")
|
||||||
|
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
child.stdout?.removeAllListeners()
|
||||||
|
child.stderr?.removeAllListeners()
|
||||||
|
child.removeListener("error", handleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
this.processes.delete(options.workspaceId)
|
||||||
|
if (!portFound) {
|
||||||
|
cleanup()
|
||||||
|
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||||
|
reject(new Error(reason))
|
||||||
|
} else {
|
||||||
|
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (error: Error) => {
|
||||||
|
cleanup()
|
||||||
|
this.processes.delete(options.workspaceId)
|
||||||
|
child.removeListener("exit", handleExit)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on("error", handleError)
|
||||||
|
child.on("exit", handleExit)
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString()
|
||||||
|
stdoutBuffer += text
|
||||||
|
const lines = stdoutBuffer.split("\n")
|
||||||
|
stdoutBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
this.emitLog(options.workspaceId, "info", line)
|
||||||
|
|
||||||
|
if (!portFound) {
|
||||||
|
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||||
|
if (portMatch) {
|
||||||
|
portFound = true
|
||||||
|
cleanup()
|
||||||
|
resolve({ pid: child.pid!, port: parseInt(portMatch[1], 10) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString()
|
||||||
|
stderrBuffer += text
|
||||||
|
const lines = stderrBuffer.split("\n")
|
||||||
|
stderrBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
this.emitLog(options.workspaceId, "error", line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
this.processes.delete(options.workspaceId)
|
||||||
|
if (!portFound) {
|
||||||
|
cleanup()
|
||||||
|
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||||
|
reject(new Error(reason))
|
||||||
|
}
|
||||||
|
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(workspaceId: string): Promise<void> {
|
||||||
|
const managed = this.processes.get(workspaceId)
|
||||||
|
if (!managed) return
|
||||||
|
|
||||||
|
managed.requestedStop = true
|
||||||
|
const child = managed.child
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onExit = () => {
|
||||||
|
child.removeListener("error", onError)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
child.removeListener("exit", onExit)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
child.once("exit", onExit)
|
||||||
|
child.once("error", onError)
|
||||||
|
|
||||||
|
child.kill("SIGTERM")
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill("SIGKILL")
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitLog(workspaceId: string, level: LogLevel, message: string) {
|
||||||
|
const entry: WorkspaceLogEntry = {
|
||||||
|
workspaceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message: message.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventBus.publish({ type: "workspace.log", entry })
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateFolder(folder: string) {
|
||||||
|
const resolved = path.resolve(folder)
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
throw new Error(`Folder does not exist: ${resolved}`)
|
||||||
|
}
|
||||||
|
const stats = statSync(resolved)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Path is not a directory: ${resolved}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/cli/tsconfig.json
Normal file
17
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -85,22 +85,16 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
|
if (!folderPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
let folder: string | null | undefined = folderPath
|
addRecentFolder(folderPath)
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
folder = await window.electronAPI.selectFolder()
|
|
||||||
if (!folder) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addRecentFolder(folder)
|
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folder, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setHasInstances(true)
|
setHasInstances(true)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
setIsAdvancedSettingsOpen(false)
|
||||||
@@ -129,8 +123,6 @@ const App: Component = () => {
|
|||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
if (hasInstances()) {
|
if (hasInstances()) {
|
||||||
setShowFolderSelection(true)
|
setShowFolderSelection(true)
|
||||||
} else {
|
|
||||||
void handleSelectFolder()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||||
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||||
|
import { cliApi } from "../lib/api-client"
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -17,7 +18,7 @@ interface FilePickerProps {
|
|||||||
instanceClient: OpencodeClient
|
instanceClient: OpencodeClient
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
textareaRef?: HTMLTextAreaElement
|
textareaRef?: HTMLTextAreaElement
|
||||||
workspaceFolder: string
|
workspaceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilePicker: Component<FilePickerProps> = (props) => {
|
const FilePicker: Component<FilePickerProps> = (props) => {
|
||||||
@@ -36,10 +37,10 @@ const FilePicker: Component<FilePickerProps> = (props) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (allFiles().length === 0) {
|
if (allFiles().length === 0) {
|
||||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`)
|
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
|
||||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||||
path,
|
path: entry.path,
|
||||||
isGitFile: false,
|
isGitFile: false,
|
||||||
}))
|
}))
|
||||||
setAllFiles(scannedFiles)
|
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 { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder?: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
advancedSettingsOpen?: boolean
|
advancedSettingsOpen?: boolean
|
||||||
onAdvancedSettingsOpen?: () => void
|
onAdvancedSettingsOpen?: () => void
|
||||||
@@ -19,6 +20,7 @@ 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 [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
@@ -173,12 +175,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleBrowse() {
|
function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
updateLastUsedBinary(selectedBinary())
|
setFocusMode("new")
|
||||||
props.onSelectFolder(undefined, selectedBinary())
|
setIsFolderBrowserOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBrowserSelect(path: string) {
|
||||||
|
setIsFolderBrowserOpen(false)
|
||||||
|
handleFolderSelect(path)
|
||||||
|
}
|
||||||
|
|
||||||
function handleBinaryChange(binary: string) {
|
function handleBinaryChange(binary: string) {
|
||||||
|
|
||||||
setSelectedBinary(binary)
|
setSelectedBinary(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +385,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onBinaryChange={handleBinaryChange}
|
onBinaryChange={handleBinaryChange}
|
||||||
isLoading={props.isLoading}
|
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 { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
|
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { cliApi } from "../lib/api-client"
|
||||||
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
|
|
||||||
interface BinaryOption {
|
interface BinaryOption {
|
||||||
path: string
|
path: string
|
||||||
@@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const [validationError, setValidationError] = createSignal<string | null>(null)
|
const [validationError, setValidationError] = createSignal<string | null>(null)
|
||||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||||
|
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||||
@@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setValidating(true)
|
setValidating(true)
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
|
|
||||||
const result = await window.electronAPI.validateOpenCodeBinary(path)
|
const result = await cliApi.validateBinary(path)
|
||||||
|
|
||||||
if (result.valid && result.version) {
|
if (result.valid && result.version) {
|
||||||
const updatedVersionInfo = new Map(versionInfo())
|
const updatedVersionInfo = new Map(versionInfo())
|
||||||
@@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBrowseBinary() {
|
function handleBrowseBinary() {
|
||||||
try {
|
if (props.disabled) return
|
||||||
const path = await window.electronAPI.selectOpenCodeBinary()
|
setValidationError(null)
|
||||||
if (!path) return
|
setIsBinaryBrowserOpen(true)
|
||||||
|
|
||||||
setCustomPath(path)
|
|
||||||
await handleValidateAndAdd(path)
|
|
||||||
} catch (error) {
|
|
||||||
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleValidateAndAdd(path: string) {
|
async function handleValidateAndAdd(path: string) {
|
||||||
const validation = await validateBinary(path)
|
const validation = await validateBinary(path)
|
||||||
|
|
||||||
@@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setValidationError(validation.error || "Invalid OpenCode binary")
|
setValidationError(validation.error || "Invalid OpenCode binary")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBinaryBrowserSelect(path: string) {
|
||||||
|
setIsBinaryBrowserOpen(false)
|
||||||
|
setCustomPath(path)
|
||||||
|
void handleValidateAndAdd(path)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCustomPathSubmit() {
|
async function handleCustomPathSubmit() {
|
||||||
|
|
||||||
const path = customPath().trim()
|
const path = customPath().trim()
|
||||||
if (!path) return
|
if (!path) return
|
||||||
await handleValidateAndAdd(path)
|
await handleValidateAndAdd(path)
|
||||||
@@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const isPathValidating = (path: string) => validatingPaths().has(path)
|
const isPathValidating = (path: string) => validatingPaths().has(path)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="panel">
|
<>
|
||||||
<div class="panel-header flex items-center justify-between gap-3">
|
<div class="panel">
|
||||||
<div>
|
<div class="panel-header flex items-center justify-between gap-3">
|
||||||
<h3 class="panel-title">OpenCode Binary</h3>
|
<div>
|
||||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
<h3 class="panel-title">OpenCode Binary</h3>
|
||||||
</div>
|
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
||||||
<Show when={validating()}>
|
</div>
|
||||||
<div class="selector-loading text-xs">
|
<Show when={validating()}>
|
||||||
<Loader2 class="selector-loading-spinner" />
|
<div class="selector-loading text-xs">
|
||||||
<span>Checking versions…</span>
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCustomPathSubmit}
|
onClick={handleBrowseBinary}
|
||||||
disabled={props.disabled || !customPath().trim()}
|
disabled={props.disabled}
|
||||||
class="selector-button selector-button-primary"
|
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<FolderOpen class="w-4 h-4" />
|
||||||
Add
|
Browse for Binary…
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
|
||||||
type="button"
|
<For each={binaryOptions()}>
|
||||||
onClick={handleBrowseBinary}
|
{(binary) => {
|
||||||
disabled={props.disabled}
|
const isDefault = binary.isDefault
|
||||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
|
||||||
>
|
|
||||||
<FolderOpen class="w-4 h-4" />
|
|
||||||
Browse for Binary…
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={validationError()}>
|
return (
|
||||||
<div class="selector-validation-error">
|
<div
|
||||||
<div class="selector-validation-error-content">
|
class="panel-list-item flex items-center"
|
||||||
<AlertCircle class="selector-validation-error-icon" />
|
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="p-2 text-muted hover:text-primary"
|
class="panel-list-item-content flex-1"
|
||||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
onClick={() => handleSelectBinary(binary.path)}
|
||||||
disabled={props.disabled}
|
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>
|
</button>
|
||||||
</Show>
|
<Show when={!isDefault}>
|
||||||
</div>
|
<button
|
||||||
)
|
type="button"
|
||||||
}}
|
class="p-2 text-muted hover:text-primary"
|
||||||
</For>
|
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>
|
||||||
</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
|
export default OpenCodeBinarySelector
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||||
const [isFocused, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
const [showPicker, setShowPicker] = createSignal(false)
|
const [showPicker, setShowPicker] = createSignal(false)
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||||
@@ -744,7 +744,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
searchQuery={searchQuery()}
|
searchQuery={searchQuery()}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
workspaceFolder={props.instanceFolder}
|
workspaceId={props.instanceId}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||||
|
import { cliApi } from "../lib/api-client"
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -19,7 +20,7 @@ interface UnifiedPickerProps {
|
|||||||
instanceClient: OpencodeClient | null
|
instanceClient: OpencodeClient | null
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
textareaRef?: HTMLTextAreaElement
|
textareaRef?: HTMLTextAreaElement
|
||||||
workspaceFolder: string
|
workspaceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||||
@@ -38,9 +39,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (allFiles().length === 0) {
|
if (allFiles().length === 0) {
|
||||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||||
path,
|
path: entry.path,
|
||||||
isGitFile: false,
|
isGitFile: false,
|
||||||
}))
|
}))
|
||||||
setAllFiles(scannedFiles)
|
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 { keyboardRegistry } from "../keyboard-registry"
|
||||||
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
||||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||||
import { addLog, updateInstance } from "../../stores/instances"
|
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
|
|
||||||
interface UseAppLifecycleOptions {
|
interface UseAppLifecycleOptions {
|
||||||
@@ -148,29 +147,6 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
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(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("keydown", handleKeyDown)
|
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 {
|
export type ConfigData = AppConfig
|
||||||
preferences: Preferences
|
|
||||||
recentFolders: RecentFolder[]
|
|
||||||
opencodeBinaries: OpenCodeBinary[]
|
|
||||||
theme?: "light" | "dark" | "system"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstanceData {
|
|
||||||
messageHistory: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileStorage {
|
export class ServerStorage {
|
||||||
private configPath: string | undefined
|
|
||||||
private instancesDir: string | undefined
|
|
||||||
private configChangeListeners: Set<() => void> = new Set()
|
private configChangeListeners: Set<() => void> = new Set()
|
||||||
private initialized = false
|
|
||||||
|
|
||||||
constructor() {
|
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> {
|
async loadConfig(): Promise<ConfigData> {
|
||||||
await this.ensureInitialized()
|
const config = await cliApi.fetchConfig()
|
||||||
try {
|
return config
|
||||||
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: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveConfig(config: ConfigData): Promise<void> {
|
async saveConfig(config: ConfigData): Promise<void> {
|
||||||
await this.ensureInitialized()
|
await cliApi.updateConfig(config)
|
||||||
try {
|
|
||||||
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save config:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance operations
|
|
||||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||||
await this.ensureInitialized()
|
return cliApi.readInstanceData(instanceId)
|
||||||
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: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||||
await this.ensureInitialized()
|
await cliApi.writeInstanceData(instanceId, data)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteInstanceData(instanceId: string): Promise<void> {
|
async deleteInstanceData(instanceId: string): Promise<void> {
|
||||||
await this.ensureInitialized()
|
await cliApi.deleteInstanceData(instanceId)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
onConfigChanged(listener: () => void): () => void {
|
||||||
this.configChangeListeners.add(listener)
|
this.configChangeListeners.add(listener)
|
||||||
return () => this.configChangeListeners.delete(listener)
|
return () => this.configChangeListeners.delete(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyConfigChanged() {
|
||||||
|
for (const listener of this.configChangeListeners) {
|
||||||
|
listener()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
export const storage = new ServerStorage()
|
||||||
export const storage = new FileStorage()
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
|||||||
import type { ClientPart, Message } from "../types/message"
|
import type { ClientPart, Message } from "../types/message"
|
||||||
import { sdkManager } from "../lib/sdk-manager"
|
import { sdkManager } from "../lib/sdk-manager"
|
||||||
import { sseManager } from "../lib/sse-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 {
|
import {
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
@@ -35,6 +38,133 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
|
|||||||
|
|
||||||
const MAX_LOG_ENTRIES = 1000
|
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) {
|
function ensureLogContainer(id: string) {
|
||||||
setInstanceLogs((prev) => {
|
setInstanceLogs((prev) => {
|
||||||
if (prev.has(id)) {
|
if (prev.has(id)) {
|
||||||
@@ -157,61 +287,17 @@ function removeInstance(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createInstance(folder: string, binaryPath?: 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 instance: Instance = {
|
|
||||||
id,
|
|
||||||
folder,
|
|
||||||
port: 0,
|
|
||||||
pid: 0,
|
|
||||||
status: "starting",
|
|
||||||
client: null,
|
|
||||||
environmentVariables: preferences().environmentVariables ?? {},
|
|
||||||
}
|
|
||||||
|
|
||||||
addInstance(instance)
|
|
||||||
|
|
||||||
// Update last used binary
|
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
updateLastUsedBinary(binaryPath)
|
updateLastUsedBinary(binaryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const workspace = await cliApi.createWorkspace({ path: folder })
|
||||||
id: returnedId,
|
upsertWorkspace(workspace)
|
||||||
port,
|
setActiveInstanceId(workspace.id)
|
||||||
pid,
|
return workspace.id
|
||||||
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
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateInstance(id, {
|
console.error("Failed to create workspace", error)
|
||||||
status: "error",
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,17 +306,18 @@ async function stopInstance(id: string) {
|
|||||||
const instance = instances().get(id)
|
const instance = instances().get(id)
|
||||||
if (!instance) return
|
if (!instance) return
|
||||||
|
|
||||||
sseManager.disconnect(id)
|
releaseInstanceResources(id)
|
||||||
|
|
||||||
if (instance.port) {
|
try {
|
||||||
sdkManager.destroyClient(instance.port)
|
await cliApi.deleteWorkspace(id)
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Failed to stop workspace", error)
|
||||||
if (instance.pid) {
|
|
||||||
await window.electronAPI.stopInstance(instance.pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeInstance(id)
|
removeInstance(id)
|
||||||
|
if (instances().size === 0) {
|
||||||
|
setHasInstances(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
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
|
const MAX_HISTORY = 100
|
||||||
|
|
||||||
@@ -48,7 +48,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await storage.loadInstanceData(instanceId)
|
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)
|
historyLoaded.add(instanceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to load history:", error)
|
console.warn("Failed to load history:", error)
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export type ExpansionPreference = "expanded" | "collapsed"
|
|||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
lastUsedBinary?: string
|
lastUsedBinary?: string
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables: Record<string, string>
|
||||||
modelRecents?: ModelPreference[]
|
modelRecents: ModelPreference[]
|
||||||
agentModelSelections?: AgentModelSelections
|
agentModelSelections: AgentModelSelections
|
||||||
diffViewMode?: DiffViewMode
|
diffViewMode: DiffViewMode
|
||||||
toolOutputExpansion?: ExpansionPreference
|
toolOutputExpansion: ExpansionPreference
|
||||||
diagnosticsExpansion?: ExpansionPreference
|
diagnosticsExpansion: ExpansionPreference
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeBinary {
|
export interface OpenCodeBinary {
|
||||||
@@ -41,6 +41,7 @@ const MAX_RECENT_MODELS = 5
|
|||||||
|
|
||||||
const defaultPreferences: Preferences = {
|
const defaultPreferences: Preferences = {
|
||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
|
environmentVariables: {},
|
||||||
modelRecents: [],
|
modelRecents: [],
|
||||||
agentModelSelections: {},
|
agentModelSelections: {},
|
||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
@@ -48,12 +49,41 @@ const defaultPreferences: Preferences = {
|
|||||||
diagnosticsExpansion: "expanded",
|
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 [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||||
let cachedConfig: ConfigData = {
|
let cachedConfig: ConfigData = {
|
||||||
preferences: defaultPreferences,
|
preferences: normalizePreferences(),
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
opencodeBinaries: [],
|
opencodeBinaries: [],
|
||||||
}
|
}
|
||||||
@@ -64,15 +94,15 @@ async function loadConfig(): Promise<void> {
|
|||||||
const config = await storage.loadConfig()
|
const config = await storage.loadConfig()
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
...config,
|
...config,
|
||||||
preferences: { ...defaultPreferences, ...config.preferences },
|
preferences: normalizePreferences(config.preferences),
|
||||||
recentFolders: config.recentFolders || [],
|
recentFolders: config.recentFolders ?? [],
|
||||||
opencodeBinaries: config.opencodeBinaries || [],
|
opencodeBinaries: config.opencodeBinaries ?? [],
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config:", error)
|
console.error("Failed to load config:", error)
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
...cachedConfig,
|
...cachedConfig,
|
||||||
preferences: { ...defaultPreferences },
|
preferences: normalizePreferences(),
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
opencodeBinaries: [],
|
opencodeBinaries: [],
|
||||||
}
|
}
|
||||||
@@ -112,7 +142,7 @@ async function ensureConfigLoaded(): Promise<void> {
|
|||||||
|
|
||||||
|
|
||||||
function updatePreferences(updates: Partial<Preferences>): void {
|
function updatePreferences(updates: Partial<Preferences>): void {
|
||||||
const updated = { ...preferences(), ...updates }
|
const updated = normalizePreferences({ ...preferences(), ...updates })
|
||||||
setPreferences(updated)
|
setPreferences(updated)
|
||||||
saveConfig().catch(console.error)
|
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" />
|
||||||
@@ -13,6 +13,12 @@ export default defineConfig({
|
|||||||
"@": resolve(__dirname, "./src"),
|
"@": resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["lucide-solid"],
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: ["lucide-solid"],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user