diff --git a/packages/cli/src/config/binaries.ts b/packages/cli/src/config/binaries.ts index e4981537..7b3d4f52 100644 --- a/packages/cli/src/config/binaries.ts +++ b/packages/cli/src/config/binaries.ts @@ -6,7 +6,7 @@ import { } from "../api-types" import { ConfigStore } from "./store" import { EventBus } from "../events/bus" -import type { ConfigFileUpdate } from "./schema" +import type { ConfigFile } from "./schema" import { Logger } from "../logger" export class BinaryRegistry { @@ -39,17 +39,15 @@ export class BinaryRegistry { } const config = this.configStore.get() - const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path) - - const update: ConfigFileUpdate = { - opencodeBinaries: [entry, ...deduped], - } + const nextConfig = this.cloneConfig(config) + const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path) + nextConfig.opencodeBinaries = [entry, ...deduped] 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) this.emitChange() return record @@ -58,19 +56,16 @@ export class BinaryRegistry { update(id: string, updates: BinaryUpdateRequest): BinaryRecord { this.logger.debug({ id }, "Updating OpenCode binary") 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, ) - const update: ConfigFileUpdate = { - opencodeBinaries: updatedEntries, - } - if (updates.makeDefault) { - update.preferences = { lastUsedBinary: id } + nextConfig.preferences.lastUsedBinary = id } - this.configStore.update(update) + this.configStore.replace(nextConfig) const record = this.getById(id) this.emitChange() return record @@ -79,14 +74,15 @@ export class BinaryRegistry { remove(id: string) { this.logger.debug({ id }, "Removing OpenCode binary") const config = this.configStore.get() - const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id) - const update: ConfigFileUpdate = { opencodeBinaries: remaining } + const nextConfig = this.cloneConfig(config) + const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id) + nextConfig.opencodeBinaries = remaining - if (config.preferences.lastUsedBinary === id) { - update.preferences = { lastUsedBinary: remaining[0]?.path } + if (nextConfig.preferences.lastUsedBinary === id) { + nextConfig.preferences.lastUsedBinary = remaining[0]?.path } - this.configStore.update(update) + this.configStore.replace(nextConfig) 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[] { + const config = this.configStore.get() const configuredBinaries = config.opencodeBinaries.map((binary) => ({ id: binary.path, diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 0bd6aa88..2ebda652 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -19,17 +19,6 @@ const PreferencesSchema = z.object({ 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(), @@ -49,13 +38,6 @@ const ConfigFileSchema = z.object({ 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 { @@ -66,7 +48,6 @@ export { RecentFolderSchema, OpenCodeBinarySchema, ConfigFileSchema, - ConfigFileUpdateSchema, DEFAULT_CONFIG, } @@ -77,4 +58,3 @@ export type Preferences = z.infer export type RecentFolder = z.infer export type OpenCodeBinary = z.infer export type ConfigFile = z.infer -export type ConfigFileUpdate = z.infer diff --git a/packages/cli/src/config/store.ts b/packages/cli/src/config/store.ts index dbfb1e88..7bfd8d2c 100644 --- a/packages/cli/src/config/store.ts +++ b/packages/cli/src/config/store.ts @@ -2,14 +2,7 @@ import fs from "fs" import path from "path" import { EventBus } from "../events/bus" import { Logger } from "../logger" -import { - AgentModelSelections, - ConfigFile, - ConfigFileUpdate, - ConfigFileSchema, - ConfigFileUpdateSchema, - DEFAULT_CONFIG, -} from "./schema" +import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema" export class ConfigStore { private cache: ConfigFile = DEFAULT_CONFIG @@ -50,54 +43,18 @@ export class ConfigStore { 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) + replace(config: ConfigFile) { + const validated = ConfigFileSchema.parse(config) + this.commit(validated) + } + + private commit(next: ConfigFile) { + this.cache = next + this.loaded = true this.persist() this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) - this.logger.debug("Config updated") - } - - 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 + this.logger.info("Config updated") + this.logger.debug({ config: this.cache }, "Config payload") } private persist() { diff --git a/packages/cli/src/server/routes/config.ts b/packages/cli/src/server/routes/config.ts index dc81ce27..fed364af 100644 --- a/packages/cli/src/server/routes/config.ts +++ b/packages/cli/src/server/routes/config.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify" import { z } from "zod" import { ConfigStore } from "../../config/store" import { BinaryRegistry } from "../../config/binaries" -import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema" +import { ConfigFileSchema } from "../../config/schema" interface RouteDeps { configStore: ConfigStore @@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { 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) + deps.configStore.replace(body) return deps.configStore.get() }) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 48780cc4..ea314e15 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -43,7 +43,7 @@ const App: Component = () => { const { isDark } = useTheme() const { preferences, - addRecentFolder, + recordWorkspaceLaunch, toggleShowThinkingBlocks, setDiffViewMode, setToolOutputExpansion, @@ -92,7 +92,7 @@ const App: Component = () => { setIsSelectingFolder(true) const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" try { - addRecentFolder(folderPath) + recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) setHasInstances(true) diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index cf1e199b..32a092b3 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -16,7 +16,7 @@ interface FolderSelectionViewProps { } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig() + const { recentFolders, removeRecentFolder, preferences } = useConfig() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") @@ -169,7 +169,6 @@ const FolderSelectionView: Component = (props) => { function handleFolderSelect(path: string) { if (isLoading()) return - updateLastUsedBinary(selectedBinary()) props.onSelectFolder(path, selectedBinary()) } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 62fdee54..eb1becfa 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -1,6 +1,5 @@ import type { AppConfig, - AppConfigUpdateRequest, BinaryCreateRequest, BinaryListResponse, BinaryUpdateRequest, @@ -115,12 +114,6 @@ export const cliApi = { body: JSON.stringify(payload), }) }, - patchConfig(payload: AppConfigUpdateRequest): Promise { - return request("/api/config/app", { - method: "PATCH", - body: JSON.stringify(payload), - }) - }, listBinaries(): Promise { return request("/api/config/binaries") }, diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index b0568674..72d4603b 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -4,21 +4,58 @@ import { cliEvents } from "./cli-events" 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 { - private configChangeListeners: Set<() => void> = new Set() + private configChangeListeners: Set<(config: ConfigData) => void> = new Set() + private configCache: ConfigData | null = null + private loadPromise: Promise | null = null 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 { - const config = await cliApi.fetchConfig() - return config + if (this.configCache) { + 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 { - await cliApi.updateConfig(config) + async updateConfig(next: ConfigData): Promise { + const nextConfig = await cliApi.updateConfig(next) + this.setConfigCache(nextConfig) + return nextConfig } async loadInstanceData(instanceId: string): Promise { @@ -33,14 +70,26 @@ export class ServerStorage { await cliApi.deleteInstanceData(instanceId) } - onConfigChanged(listener: () => void): () => void { + onConfigChanged(listener: (config: ConfigData) => void): () => void { this.configChangeListeners.add(listener) + if (this.configCache) { + listener(this.configCache) + } 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) { - listener() + listener(config) } } } diff --git a/packages/ui/src/lib/theme.tsx b/packages/ui/src/lib/theme.tsx index ac2c0eda..96b59551 100644 --- a/packages/ui/src/lib/theme.tsx +++ b/packages/ui/src/lib/theme.tsx @@ -1,5 +1,5 @@ -import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js" -import { storage, type ConfigData } from "./storage" +import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js" +import { useConfig } from "../stores/preferences" interface ThemeContextValue { isDark: () => boolean @@ -20,64 +20,30 @@ function applyTheme(dark: boolean) { export function ThemeProvider(props: { children: JSX.Element }) { const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)") - const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches) - let themePreference: "system" | "dark" | "light" = "dark" + const { themePreference, setThemePreference } = useConfig() + const [isDark, setIsDarkSignal] = createSignal(false) - applyTheme(true) //systemPrefersDark.matches) - - async function loadTheme() { - try { - 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) + const resolveDarkTheme = () => { + const preference = themePreference() + if (preference === "system") { + return systemPrefersDark.matches } + return preference === "dark" } - async function saveTheme(dark: boolean) { - try { - const config = await storage.loadConfig() - const nextPreference = dark ? "dark" : "light" - config.theme = nextPreference - themePreference = nextPreference - await storage.saveConfig(config) - } catch (error) { - console.warn("Failed to save theme to config:", error) - } + const applyResolvedTheme = () => { + const dark = resolveDarkTheme() + setIsDarkSignal(dark) + applyTheme(dark) } + createEffect(() => { + applyResolvedTheme() + }) + onMount(() => { - loadTheme() - - const unsubscribe = storage.onConfigChanged(() => { - loadTheme() - }) - - // Listen for system theme changes const handleSystemThemeChange = (event: MediaQueryListEvent) => { - if (themePreference === "system") { + if (themePreference() === "system") { setIsDarkSignal(event.matches) applyTheme(event.matches) } @@ -86,19 +52,12 @@ export function ThemeProvider(props: { children: JSX.Element }) { systemPrefersDark.addEventListener("change", handleSystemThemeChange) return () => { - unsubscribe() systemPrefersDark.removeEventListener("change", handleSystemThemeChange) } }) - createEffect(() => { - applyTheme(isDark()) - }) - const setTheme = (dark: boolean) => { - setIsDarkSignal(dark) - applyTheme(dark) - saveTheme(dark) + setThemePreference(dark ? "dark" : "light") } const toggleTheme = () => { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index ccd80776..ed0c0bfa 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -15,7 +15,7 @@ import { clearInstanceDraftPrompts, } from "./sessions" import { fetchCommands, clearCommands } from "./commands" -import { preferences, updateLastUsedBinary } from "./preferences" +import { preferences } from "./preferences" import { computeDisplayParts } from "./session-messages" import { withSession, setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" @@ -294,11 +294,7 @@ function removeInstance(id: string) { clearInstanceDraftPrompts(id) } -async function createInstance(folder: string, binaryPath?: string): Promise { - if (binaryPath) { - updateLastUsedBinary(binaryPath) - } - +async function createInstance(folder: string, _binaryPath?: string): Promise { try { const workspace = await cliApi.createWorkspace({ path: folder }) upsertWorkspace(workspace) diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index e17bd1fb..b54dbe85 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -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 { storage, type ConfigData } from "../lib/storage" +type DeepReadonly = T extends (...args: any[]) => unknown + ? T + : T extends Array + ? ReadonlyArray> + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T + export interface ModelPreference { providerId: string modelId: string @@ -36,6 +44,8 @@ export interface RecentFolder { lastAccessed: number } +export type ThemePreference = NonNullable + const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_MODELS = 5 @@ -49,6 +59,18 @@ const defaultPreferences: Preferences = { 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 { const environmentVariables = { ...defaultPreferences.environmentVariables, @@ -78,73 +100,148 @@ function normalizePreferences(pref?: Partial): Preferences { } } -const [preferences, setPreferences] = createSignal(normalizePreferences()) -const [recentFolders, setRecentFolders] = createSignal([]) -const [opencodeBinaries, setOpenCodeBinaries] = createSignal([]) +const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) +const config = createMemo>(() => internalConfig()) const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) -let cachedConfig: ConfigData = { - preferences: normalizePreferences(), - recentFolders: [], - opencodeBinaries: [], -} +const preferences = createMemo(() => internalConfig().preferences) +const recentFolders = createMemo(() => internalConfig().recentFolders ?? []) +const opencodeBinaries = createMemo(() => internalConfig().opencodeBinaries ?? []) +const themePreference = createMemo(() => internalConfig().theme ?? "dark") let loadPromise: Promise | null = null -async function loadConfig(): Promise { +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 { try { - const config = await storage.loadConfig() - cachedConfig = { - ...config, - preferences: normalizePreferences(config.preferences), - recentFolders: config.recentFolders ?? [], - opencodeBinaries: config.opencodeBinaries ?? [], - } + const configData = source ?? (await storage.loadConfig()) + applyConfig(configData) } catch (error) { console.error("Failed to load config:", error) - cachedConfig = { - ...cachedConfig, - preferences: normalizePreferences(), - recentFolders: [], - opencodeBinaries: [], - } + applyConfig(buildFallbackConfig()) } +} - setPreferences(cachedConfig.preferences) - setRecentFolders(cachedConfig.recentFolders) - setOpenCodeBinaries(cachedConfig.opencodeBinaries) +function applyConfig(next: ConfigData) { + setInternalConfig(normalizeConfig(next)) setIsConfigLoaded(true) } -async function saveConfig(): Promise { +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) + const nextKeys = Object.keys(next as Record) + const allKeys = new Set([...prevKeys, ...nextKeys]) + const changes: string[] = [] + + for (const key of allKeys) { + const childPath = [...path, key] + const prevValue = (previous as Record)[key] + const nextValue = (next as Record)[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 { try { await ensureConfigLoaded() - const config: ConfigData = { - ...cachedConfig, - preferences: preferences(), - recentFolders: recentFolders(), - opencodeBinaries: opencodeBinaries(), - } - cachedConfig = config - await storage.saveConfig(config) + await storage.updateConfig(next) } catch (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 { if (isConfigLoaded()) return if (!loadPromise) { - loadPromise = loadConfig().finally(() => { + loadPromise = syncConfig().finally(() => { loadPromise = null }) } 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): void { - const updated = normalizePreferences({ ...preferences(), ...updates }) - setPreferences(updated) - saveConfig().catch(console.error) + const current = internalConfig().preferences + const merged = normalizePreferences({ ...current, ...updates }) + if (deepEqual(current, merged)) { + return + } + updateConfig((draft) => { + draft.preferences = merged + }) } function setDiffViewMode(mode: DiffViewMode): void { @@ -167,54 +264,44 @@ function toggleShowThinkingBlocks(): void { } function addRecentFolder(path: string): void { - const folders = recentFolders().filter((f) => f.path !== path) - folders.unshift({ path, lastAccessed: Date.now() }) - - const trimmed = folders.slice(0, MAX_RECENT_FOLDERS) - setRecentFolders(trimmed) - saveConfig().catch(console.error) + updateConfig((draft) => { + draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) + }) } function removeRecentFolder(path: string): void { - const folders = recentFolders().filter((f) => f.path !== path) - setRecentFolders(folders) - saveConfig().catch(console.error) + updateConfig((draft) => { + draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path) + }) } function addOpenCodeBinary(path: string, version?: string): void { - const binaries = opencodeBinaries().filter((b) => b.path !== path) - const lastUsed = Date.now() - 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) + updateConfig((draft) => { + draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries) + }) } function removeOpenCodeBinary(path: string): void { - const binaries = opencodeBinaries().filter((b) => b.path !== path) - setOpenCodeBinaries(binaries) - saveConfig().catch(console.error) + updateConfig((draft) => { + draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path) + }) } 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() - let binary = binaries.find((b) => b.path === path) - - // If binary not found in list, add it (for system PATH "opencode") - if (!binary) { - addOpenCodeBinary(path) - 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 recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { + updateConfig((draft) => { + const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode" + draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders) + draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary }) + draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries) + }) } function updateEnvironmentVariables(envVars: Record): void { @@ -264,15 +351,19 @@ function getAgentModelPreference(instanceId: string, agent: string): ModelPrefer return preferences().agentModelSelections?.[instanceId]?.[agent] } -void ensureConfigLoaded().catch((error) => { +void ensureConfigLoaded().catch((error: unknown) => { console.error("Failed to initialize config:", error) }) interface ConfigContextValue { isLoaded: Accessor + config: typeof config preferences: typeof preferences recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries + themePreference: typeof themePreference + setThemePreference: typeof setThemePreference + updateConfig: typeof updateConfig toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks setDiffViewMode: typeof setDiffViewMode setToolOutputExpansion: typeof setToolOutputExpansion @@ -282,6 +373,7 @@ interface ConfigContextValue { addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary updateLastUsedBinary: typeof updateLastUsedBinary + recordWorkspaceLaunch: typeof recordWorkspaceLaunch updatePreferences: typeof updatePreferences updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable @@ -295,9 +387,13 @@ const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded: isConfigLoaded, + config, preferences, recentFolders, opencodeBinaries, + themePreference, + setThemePreference, + updateConfig, toggleShowThinkingBlocks, setDiffViewMode, setToolOutputExpansion, @@ -307,6 +403,7 @@ const configContextValue: ConfigContextValue = { addOpenCodeBinary, removeOpenCodeBinary, updateLastUsedBinary, + recordWorkspaceLaunch, updatePreferences, updateEnvironmentVariables, addEnvironmentVariable, @@ -318,12 +415,12 @@ const configContextValue: ConfigContextValue = { const ConfigProvider: ParentComponent = (props) => { onMount(() => { - ensureConfigLoaded().catch((error) => { + ensureConfigLoaded().catch((error: unknown) => { console.error("Failed to initialize config:", error) }) - const unsubscribe = storage.onConfigChanged(() => { - loadConfig().catch((error) => { + const unsubscribe = storage.onConfigChanged((config) => { + syncConfig(config).catch((error: unknown) => { console.error("Failed to refresh config:", error) }) }) @@ -347,7 +444,9 @@ function useConfig(): ConfigContextValue { export { ConfigProvider, useConfig, + config, preferences, + updateConfig, updatePreferences, toggleShowThinkingBlocks, recentFolders, @@ -366,4 +465,7 @@ export { setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, + themePreference, + setThemePreference, + recordWorkspaceLaunch, }