refine config provider and full replacement flow

This commit is contained in:
Shantur Rathore
2025-11-19 14:43:47 +00:00
parent 7aa94e7a88
commit 7e95005d8c
11 changed files with 296 additions and 266 deletions

View File

@@ -6,7 +6,7 @@ import {
} from "../api-types" } from "../api-types"
import { ConfigStore } from "./store" import { ConfigStore } from "./store"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import type { ConfigFileUpdate } from "./schema" import type { ConfigFile } from "./schema"
import { Logger } from "../logger" import { Logger } from "../logger"
export class BinaryRegistry { export class BinaryRegistry {
@@ -39,17 +39,15 @@ export class BinaryRegistry {
} }
const config = this.configStore.get() const config = this.configStore.get()
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path) const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
const update: ConfigFileUpdate = { nextConfig.opencodeBinaries = [entry, ...deduped]
opencodeBinaries: [entry, ...deduped],
}
if (request.makeDefault) { if (request.makeDefault) {
update.preferences = { lastUsedBinary: request.path } nextConfig.preferences.lastUsedBinary = request.path
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
const record = this.getById(request.path) const record = this.getById(request.path)
this.emitChange() this.emitChange()
return record return record
@@ -58,19 +56,16 @@ export class BinaryRegistry {
update(id: string, updates: BinaryUpdateRequest): BinaryRecord { update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary") this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get() const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) => const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
) )
const update: ConfigFileUpdate = {
opencodeBinaries: updatedEntries,
}
if (updates.makeDefault) { if (updates.makeDefault) {
update.preferences = { lastUsedBinary: id } nextConfig.preferences.lastUsedBinary = id
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
const record = this.getById(id) const record = this.getById(id)
this.emitChange() this.emitChange()
return record return record
@@ -79,14 +74,15 @@ export class BinaryRegistry {
remove(id: string) { remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary") this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get() const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id) const nextConfig = this.cloneConfig(config)
const update: ConfigFileUpdate = { opencodeBinaries: remaining } const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (config.preferences.lastUsedBinary === id) { if (nextConfig.preferences.lastUsedBinary === id) {
update.preferences = { lastUsedBinary: remaining[0]?.path } nextConfig.preferences.lastUsedBinary = remaining[0]?.path
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
this.emitChange() this.emitChange()
} }
@@ -100,7 +96,12 @@ export class BinaryRegistry {
}) })
} }
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] { private mapRecords(): BinaryRecord[] {
const config = this.configStore.get() const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({ const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path, id: binary.path,

View File

@@ -19,17 +19,6 @@ const PreferencesSchema = z.object({
diagnosticsExpansion: 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({ const RecentFolderSchema = z.object({
path: z.string(), path: z.string(),
lastAccessed: z.number().nonnegative(), lastAccessed: z.number().nonnegative(),
@@ -49,13 +38,6 @@ const ConfigFileSchema = z.object({
theme: z.enum(["light", "dark", "system"]).optional(), 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({}) const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export { export {
@@ -66,7 +48,6 @@ export {
RecentFolderSchema, RecentFolderSchema,
OpenCodeBinarySchema, OpenCodeBinarySchema,
ConfigFileSchema, ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
} }
@@ -77,4 +58,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema> export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema> export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema> export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>

View File

@@ -2,14 +2,7 @@ import fs from "fs"
import path from "path" import path from "path"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { Logger } from "../logger" import { Logger } from "../logger"
import { import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
AgentModelSelections,
ConfigFile,
ConfigFileUpdate,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
} from "./schema"
export class ConfigStore { export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG private cache: ConfigFile = DEFAULT_CONFIG
@@ -50,54 +43,18 @@ export class ConfigStore {
return this.load() return this.load()
} }
update(partial: ConfigFile | ConfigFileUpdate) { replace(config: ConfigFile) {
const safePartial = const validated = ConfigFileSchema.parse(config)
"recentFolders" in partial && "opencodeBinaries" in partial this.commit(validated)
? ConfigFileSchema.parse(partial) }
: ConfigFileUpdateSchema.parse(partial ?? {})
const merged = this.mergeConfig(this.load(), safePartial) private commit(next: ConfigFile) {
this.cache = ConfigFileSchema.parse(merged) this.cache = next
this.loaded = true
this.persist() this.persist()
this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug("Config updated") this.logger.info("Config updated")
} this.logger.debug({ config: this.cache }, "Config payload")
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() { private persist() {

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { ConfigStore } from "../../config/store" import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries" import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema" import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps { interface RouteDeps {
configStore: ConfigStore configStore: ConfigStore
@@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put("/api/config/app", async (request) => { app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {}) const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.update(body) deps.configStore.replace(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() return deps.configStore.get()
}) })

View File

@@ -43,7 +43,7 @@ const App: Component = () => {
const { isDark } = useTheme() const { isDark } = useTheme()
const { const {
preferences, preferences,
addRecentFolder, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
@@ -92,7 +92,7 @@ const App: Component = () => {
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
addRecentFolder(folderPath) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true) setHasInstances(true)

View File

@@ -16,7 +16,7 @@ interface FolderSelectionViewProps {
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig() const { recentFolders, removeRecentFolder, preferences } = useConfig()
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")
@@ -169,7 +169,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleFolderSelect(path: string) { function handleFolderSelect(path: string) {
if (isLoading()) return if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }

View File

@@ -1,6 +1,5 @@
import type { import type {
AppConfig, AppConfig,
AppConfigUpdateRequest,
BinaryCreateRequest, BinaryCreateRequest,
BinaryListResponse, BinaryListResponse,
BinaryUpdateRequest, BinaryUpdateRequest,
@@ -115,12 +114,6 @@ export const cliApi = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PATCH",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> { listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries") return request<BinaryListResponse>("/api/config/binaries")
}, },

View File

@@ -4,21 +4,58 @@ import { cliEvents } from "./cli-events"
export type ConfigData = AppConfig export type ConfigData = AppConfig
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true
}
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch (error) {
console.warn("Failed to compare config objects", error)
}
}
return false
}
export class ServerStorage { export class ServerStorage {
private configChangeListeners: Set<() => void> = new Set() private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
private configCache: ConfigData | null = null
private loadPromise: Promise<ConfigData> | null = null
constructor() { constructor() {
cliEvents.on("config.appChanged", () => this.notifyConfigChanged()) cliEvents.on("config.appChanged", (event) => {
if (event.type !== "config.appChanged") return
this.setConfigCache(event.config)
})
} }
async loadConfig(): Promise<ConfigData> { async loadConfig(): Promise<ConfigData> {
const config = await cliApi.fetchConfig() if (this.configCache) {
return config return this.configCache
}
if (!this.loadPromise) {
this.loadPromise = cliApi
.fetchConfig()
.then((config) => {
this.setConfigCache(config)
return config
})
.finally(() => {
this.loadPromise = null
})
}
return this.loadPromise
} }
async saveConfig(config: ConfigData): Promise<void> { async updateConfig(next: ConfigData): Promise<ConfigData> {
await cliApi.updateConfig(config) const nextConfig = await cliApi.updateConfig(next)
this.setConfigCache(nextConfig)
return nextConfig
} }
async loadInstanceData(instanceId: string): Promise<InstanceData> { async loadInstanceData(instanceId: string): Promise<InstanceData> {
@@ -33,14 +70,26 @@ export class ServerStorage {
await cliApi.deleteInstanceData(instanceId) await cliApi.deleteInstanceData(instanceId)
} }
onConfigChanged(listener: () => void): () => void { onConfigChanged(listener: (config: ConfigData) => void): () => void {
this.configChangeListeners.add(listener) this.configChangeListeners.add(listener)
if (this.configCache) {
listener(this.configCache)
}
return () => this.configChangeListeners.delete(listener) return () => this.configChangeListeners.delete(listener)
} }
private notifyConfigChanged() { private setConfigCache(config: ConfigData) {
if (this.configCache && isDeepEqual(this.configCache, config)) {
this.configCache = config
return
}
this.configCache = config
this.notifyConfigChanged(config)
}
private notifyConfigChanged(config: ConfigData) {
for (const listener of this.configChangeListeners) { for (const listener of this.configChangeListeners) {
listener() listener(config)
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js" import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
import { storage, type ConfigData } from "./storage" import { useConfig } from "../stores/preferences"
interface ThemeContextValue { interface ThemeContextValue {
isDark: () => boolean isDark: () => boolean
@@ -20,64 +20,30 @@ function applyTheme(dark: boolean) {
export function ThemeProvider(props: { children: JSX.Element }) { export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)") const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches) const { themePreference, setThemePreference } = useConfig()
let themePreference: "system" | "dark" | "light" = "dark" const [isDark, setIsDarkSignal] = createSignal(false)
applyTheme(true) //systemPrefersDark.matches) const resolveDarkTheme = () => {
const preference = themePreference()
async function loadTheme() { if (preference === "system") {
try { return systemPrefersDark.matches
const config = await storage.loadConfig()
const savedTheme = config.theme
let themeDark: boolean
if (savedTheme === "system") {
themePreference = "system"
themeDark = systemPrefersDark.matches
} else if (savedTheme === "dark") {
themePreference = "dark"
themeDark = true
} else if (savedTheme === "light") {
themePreference = "light"
themeDark = false
} else {
themePreference = "dark"
themeDark = true
}
setIsDarkSignal(themeDark)
applyTheme(themeDark)
} catch (error) {
console.warn("Failed to load theme from config:", error)
themePreference = "dark"
const themeDark = true
setIsDarkSignal(themeDark)
applyTheme(themeDark)
} }
return preference === "dark"
} }
async function saveTheme(dark: boolean) { const applyResolvedTheme = () => {
try { const dark = resolveDarkTheme()
const config = await storage.loadConfig() setIsDarkSignal(dark)
const nextPreference = dark ? "dark" : "light" applyTheme(dark)
config.theme = nextPreference
themePreference = nextPreference
await storage.saveConfig(config)
} catch (error) {
console.warn("Failed to save theme to config:", error)
}
} }
createEffect(() => {
applyResolvedTheme()
})
onMount(() => { onMount(() => {
loadTheme()
const unsubscribe = storage.onConfigChanged(() => {
loadTheme()
})
// Listen for system theme changes
const handleSystemThemeChange = (event: MediaQueryListEvent) => { const handleSystemThemeChange = (event: MediaQueryListEvent) => {
if (themePreference === "system") { if (themePreference() === "system") {
setIsDarkSignal(event.matches) setIsDarkSignal(event.matches)
applyTheme(event.matches) applyTheme(event.matches)
} }
@@ -86,19 +52,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
systemPrefersDark.addEventListener("change", handleSystemThemeChange) systemPrefersDark.addEventListener("change", handleSystemThemeChange)
return () => { return () => {
unsubscribe()
systemPrefersDark.removeEventListener("change", handleSystemThemeChange) systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
} }
}) })
createEffect(() => {
applyTheme(isDark())
})
const setTheme = (dark: boolean) => { const setTheme = (dark: boolean) => {
setIsDarkSignal(dark) setThemePreference(dark ? "dark" : "light")
applyTheme(dark)
saveTheme(dark)
} }
const toggleTheme = () => { const toggleTheme = () => {

View File

@@ -15,7 +15,7 @@ import {
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
} from "./sessions" } from "./sessions"
import { fetchCommands, clearCommands } from "./commands" import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences" import { preferences } from "./preferences"
import { computeDisplayParts } from "./session-messages" import { computeDisplayParts } from "./session-messages"
import { withSession, setSessionPendingPermission } from "./session-state" import { withSession, setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui" import { setHasInstances } from "./ui"
@@ -294,11 +294,7 @@ function removeInstance(id: string) {
clearInstanceDraftPrompts(id) clearInstanceDraftPrompts(id)
} }
async function createInstance(folder: string, binaryPath?: string): Promise<string> { async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
if (binaryPath) {
updateLastUsedBinary(binaryPath)
}
try { try {
const workspace = await cliApi.createWorkspace({ path: folder }) const workspace = await cliApi.createWorkspace({ path: folder })
upsertWorkspace(workspace) upsertWorkspace(workspace)

View File

@@ -1,7 +1,15 @@
import { createContext, createSignal, onMount, useContext } from "solid-js" import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
import type { Accessor, ParentComponent } from "solid-js" import type { Accessor, ParentComponent } from "solid-js"
import { storage, type ConfigData } from "../lib/storage" import { storage, type ConfigData } from "../lib/storage"
type DeepReadonly<T> = T extends (...args: any[]) => unknown
? T
: T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
export interface ModelPreference { export interface ModelPreference {
providerId: string providerId: string
modelId: string modelId: string
@@ -36,6 +44,8 @@ export interface RecentFolder {
lastAccessed: number lastAccessed: number
} }
export type ThemePreference = NonNullable<ConfigData["theme"]>
const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_FOLDERS = 20
const MAX_RECENT_MODELS = 5 const MAX_RECENT_MODELS = 5
@@ -49,6 +59,18 @@ const defaultPreferences: Preferences = {
diagnosticsExpansion: "expanded", diagnosticsExpansion: "expanded",
} }
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch (error) {
console.warn("Failed to compare preference values", error)
}
}
return false
}
function normalizePreferences(pref?: Partial<Preferences>): Preferences { function normalizePreferences(pref?: Partial<Preferences>): Preferences {
const environmentVariables = { const environmentVariables = {
...defaultPreferences.environmentVariables, ...defaultPreferences.environmentVariables,
@@ -78,73 +100,148 @@ function normalizePreferences(pref?: Partial<Preferences>): Preferences {
} }
} }
const [preferences, setPreferences] = createSignal<Preferences>(normalizePreferences()) const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([]) const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
let cachedConfig: ConfigData = { const preferences = createMemo<Preferences>(() => internalConfig().preferences)
preferences: normalizePreferences(), const recentFolders = createMemo<RecentFolder[]>(() => internalConfig().recentFolders ?? [])
recentFolders: [], const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => internalConfig().opencodeBinaries ?? [])
opencodeBinaries: [], const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "dark")
}
let loadPromise: Promise<void> | null = null let loadPromise: Promise<void> | null = null
async function loadConfig(): Promise<void> { function normalizeConfig(config?: ConfigData | null): ConfigData {
return {
preferences: normalizePreferences(config?.preferences),
recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })),
opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })),
theme: config?.theme ?? "dark",
}
}
function buildFallbackConfig(): ConfigData {
return normalizeConfig()
}
async function syncConfig(source?: ConfigData): Promise<void> {
try { try {
const config = await storage.loadConfig() const configData = source ?? (await storage.loadConfig())
cachedConfig = { applyConfig(configData)
...config,
preferences: normalizePreferences(config.preferences),
recentFolders: config.recentFolders ?? [],
opencodeBinaries: config.opencodeBinaries ?? [],
}
} catch (error) { } catch (error) {
console.error("Failed to load config:", error) console.error("Failed to load config:", error)
cachedConfig = { applyConfig(buildFallbackConfig())
...cachedConfig,
preferences: normalizePreferences(),
recentFolders: [],
opencodeBinaries: [],
}
} }
}
setPreferences(cachedConfig.preferences) function applyConfig(next: ConfigData) {
setRecentFolders(cachedConfig.recentFolders) setInternalConfig(normalizeConfig(next))
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
setIsConfigLoaded(true) setIsConfigLoaded(true)
} }
async function saveConfig(): Promise<void> { function cloneConfigForUpdate(): ConfigData {
return normalizeConfig(internalConfig())
}
function logConfigDiff(previous: ConfigData, next: ConfigData) {
if (deepEqual(previous, next)) {
return
}
const changes = diffObjects(previous, next)
if (changes.length > 0) {
console.debug("[Config] Changes", changes)
}
}
function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] {
if (previous === next) {
return []
}
if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) {
return [path.join(".")]
}
const prevKeys = Object.keys(previous as Record<string, unknown>)
const nextKeys = Object.keys(next as Record<string, unknown>)
const allKeys = new Set([...prevKeys, ...nextKeys])
const changes: string[] = []
for (const key of allKeys) {
const childPath = [...path, key]
const prevValue = (previous as Record<string, unknown>)[key]
const nextValue = (next as Record<string, unknown>)[key]
changes.push(...diffObjects(prevValue, nextValue, childPath))
}
return changes
}
function updateConfig(mutator: (draft: ConfigData) => void): void {
const previous = internalConfig()
const draft = cloneConfigForUpdate()
mutator(draft)
logConfigDiff(previous, draft)
applyConfig(draft)
void persistFullConfig(draft)
}
async function persistFullConfig(next: ConfigData): Promise<void> {
try { try {
await ensureConfigLoaded() await ensureConfigLoaded()
const config: ConfigData = { await storage.updateConfig(next)
...cachedConfig,
preferences: preferences(),
recentFolders: recentFolders(),
opencodeBinaries: opencodeBinaries(),
}
cachedConfig = config
await storage.saveConfig(config)
} catch (error) { } catch (error) {
console.error("Failed to save config:", error) console.error("Failed to save config:", error)
void syncConfig().catch((syncError: unknown) => {
console.error("Failed to refresh config:", syncError)
})
} }
} }
function setThemePreference(preference: ThemePreference): void {
if (themePreference() === preference) {
return
}
updateConfig((draft) => {
draft.theme = preference
})
}
async function ensureConfigLoaded(): Promise<void> { async function ensureConfigLoaded(): Promise<void> {
if (isConfigLoaded()) return if (isConfigLoaded()) return
if (!loadPromise) { if (!loadPromise) {
loadPromise = loadConfig().finally(() => { loadPromise = syncConfig().finally(() => {
loadPromise = null loadPromise = null
}) })
} }
await loadPromise await loadPromise
} }
function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] {
const folders = source.filter((f) => f.path !== path)
folders.unshift({ path, lastAccessed: Date.now() })
return folders.slice(0, MAX_RECENT_FOLDERS)
}
function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] {
const timestamp = Date.now()
const existing = source.find((b) => b.path === path)
if (existing) {
const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp }
const remaining = source.filter((b) => b.path !== path)
return [updatedEntry, ...remaining]
}
const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp }
return [nextEntry, ...source].slice(0, 10)
}
function updatePreferences(updates: Partial<Preferences>): void { function updatePreferences(updates: Partial<Preferences>): void {
const updated = normalizePreferences({ ...preferences(), ...updates }) const current = internalConfig().preferences
setPreferences(updated) const merged = normalizePreferences({ ...current, ...updates })
saveConfig().catch(console.error) if (deepEqual(current, merged)) {
return
}
updateConfig((draft) => {
draft.preferences = merged
})
} }
function setDiffViewMode(mode: DiffViewMode): void { function setDiffViewMode(mode: DiffViewMode): void {
@@ -167,54 +264,44 @@ function toggleShowThinkingBlocks(): void {
} }
function addRecentFolder(path: string): void { function addRecentFolder(path: string): void {
const folders = recentFolders().filter((f) => f.path !== path) updateConfig((draft) => {
folders.unshift({ path, lastAccessed: Date.now() }) draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
})
const trimmed = folders.slice(0, MAX_RECENT_FOLDERS)
setRecentFolders(trimmed)
saveConfig().catch(console.error)
} }
function removeRecentFolder(path: string): void { function removeRecentFolder(path: string): void {
const folders = recentFolders().filter((f) => f.path !== path) updateConfig((draft) => {
setRecentFolders(folders) draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path)
saveConfig().catch(console.error) })
} }
function addOpenCodeBinary(path: string, version?: string): void { function addOpenCodeBinary(path: string, version?: string): void {
const binaries = opencodeBinaries().filter((b) => b.path !== path) updateConfig((draft) => {
const lastUsed = Date.now() draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries)
const binaryEntry: OpenCodeBinary = version ? { path, version, lastUsed } : { path, lastUsed } })
binaries.unshift(binaryEntry)
const trimmed = binaries.slice(0, 10) // Keep max 10 binaries
setOpenCodeBinaries(trimmed)
saveConfig().catch(console.error)
} }
function removeOpenCodeBinary(path: string): void { function removeOpenCodeBinary(path: string): void {
const binaries = opencodeBinaries().filter((b) => b.path !== path) updateConfig((draft) => {
setOpenCodeBinaries(binaries) draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path)
saveConfig().catch(console.error) })
} }
function updateLastUsedBinary(path: string): void { function updateLastUsedBinary(path: string): void {
updatePreferences({ lastUsedBinary: path }) const target = path || preferences().lastUsedBinary || "opencode"
updateConfig((draft) => {
draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target })
draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries)
})
}
const binaries = opencodeBinaries() function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
let binary = binaries.find((b) => b.path === path) updateConfig((draft) => {
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode"
// If binary not found in list, add it (for system PATH "opencode") draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders)
if (!binary) { draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary })
addOpenCodeBinary(path) draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries)
binary = { path, lastUsed: Date.now() } })
} else {
binary.lastUsed = Date.now()
// Move to front
const sorted = [binary, ...binaries.filter((b) => b.path !== path)]
setOpenCodeBinaries(sorted)
saveConfig().catch(console.error)
}
} }
function updateEnvironmentVariables(envVars: Record<string, string>): void { function updateEnvironmentVariables(envVars: Record<string, string>): void {
@@ -264,15 +351,19 @@ function getAgentModelPreference(instanceId: string, agent: string): ModelPrefer
return preferences().agentModelSelections?.[instanceId]?.[agent] return preferences().agentModelSelections?.[instanceId]?.[agent]
} }
void ensureConfigLoaded().catch((error) => { void ensureConfigLoaded().catch((error: unknown) => {
console.error("Failed to initialize config:", error) console.error("Failed to initialize config:", error)
}) })
interface ConfigContextValue { interface ConfigContextValue {
isLoaded: Accessor<boolean> isLoaded: Accessor<boolean>
config: typeof config
preferences: typeof preferences preferences: typeof preferences
recentFolders: typeof recentFolders recentFolders: typeof recentFolders
opencodeBinaries: typeof opencodeBinaries opencodeBinaries: typeof opencodeBinaries
themePreference: typeof themePreference
setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
setDiffViewMode: typeof setDiffViewMode setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion setToolOutputExpansion: typeof setToolOutputExpansion
@@ -282,6 +373,7 @@ interface ConfigContextValue {
addOpenCodeBinary: typeof addOpenCodeBinary addOpenCodeBinary: typeof addOpenCodeBinary
removeOpenCodeBinary: typeof removeOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary
updateLastUsedBinary: typeof updateLastUsedBinary updateLastUsedBinary: typeof updateLastUsedBinary
recordWorkspaceLaunch: typeof recordWorkspaceLaunch
updatePreferences: typeof updatePreferences updatePreferences: typeof updatePreferences
updateEnvironmentVariables: typeof updateEnvironmentVariables updateEnvironmentVariables: typeof updateEnvironmentVariables
addEnvironmentVariable: typeof addEnvironmentVariable addEnvironmentVariable: typeof addEnvironmentVariable
@@ -295,9 +387,13 @@ const ConfigContext = createContext<ConfigContextValue>()
const configContextValue: ConfigContextValue = { const configContextValue: ConfigContextValue = {
isLoaded: isConfigLoaded, isLoaded: isConfigLoaded,
config,
preferences, preferences,
recentFolders, recentFolders,
opencodeBinaries, opencodeBinaries,
themePreference,
setThemePreference,
updateConfig,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
@@ -307,6 +403,7 @@ const configContextValue: ConfigContextValue = {
addOpenCodeBinary, addOpenCodeBinary,
removeOpenCodeBinary, removeOpenCodeBinary,
updateLastUsedBinary, updateLastUsedBinary,
recordWorkspaceLaunch,
updatePreferences, updatePreferences,
updateEnvironmentVariables, updateEnvironmentVariables,
addEnvironmentVariable, addEnvironmentVariable,
@@ -318,12 +415,12 @@ const configContextValue: ConfigContextValue = {
const ConfigProvider: ParentComponent = (props) => { const ConfigProvider: ParentComponent = (props) => {
onMount(() => { onMount(() => {
ensureConfigLoaded().catch((error) => { ensureConfigLoaded().catch((error: unknown) => {
console.error("Failed to initialize config:", error) console.error("Failed to initialize config:", error)
}) })
const unsubscribe = storage.onConfigChanged(() => { const unsubscribe = storage.onConfigChanged((config) => {
loadConfig().catch((error) => { syncConfig(config).catch((error: unknown) => {
console.error("Failed to refresh config:", error) console.error("Failed to refresh config:", error)
}) })
}) })
@@ -347,7 +444,9 @@ function useConfig(): ConfigContextValue {
export { export {
ConfigProvider, ConfigProvider,
useConfig, useConfig,
config,
preferences, preferences,
updateConfig,
updatePreferences, updatePreferences,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
recentFolders, recentFolders,
@@ -366,4 +465,7 @@ export {
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
themePreference,
setThemePreference,
recordWorkspaceLaunch,
} }