-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathconfig.tsx
More file actions
190 lines (174 loc) · 6.36 KB
/
Copy pathconfig.tsx
File metadata and controls
190 lines (174 loc) · 6.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { createContext, useContext, createSignal, onMount, onCleanup, type ParentProps } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useSDK } from "./sdk"
import { useEvents } from "./events"
import type { Config, PermissionConfig, PermissionActionConfig, PermissionRuleConfig } from "../sdk/client"
interface ConfigContextValue {
/** Project-scoped config (from opencode.json in project root) */
project: Config
/** Global config (from ~/.config/opencode/opencode.json) */
global: Config
loading: () => boolean
/** True only during the very first config fetch (before any data is available) */
initialLoading: () => boolean
error: () => string | null
/** Update project config (deep merge). Returns updated config or null on error. */
updateProject: (patch: Config) => Promise<Config | null>
/** Update global config (deep merge). Returns updated config or null on error. */
updateGlobal: (patch: Config) => Promise<Config | null>
/** Reload both configs from the backend */
refresh: () => Promise<void>
}
const ConfigContext = createContext<ConfigContextValue>()
export function ConfigProvider(props: ParentProps) {
const sdk = useSDK()
const events = useEvents()
const [project, setProject] = createStore<Config>({})
const [global, setGlobal] = createStore<Config>({})
const [loading, setLoading] = createSignal(true)
const [initialLoading, setInitialLoading] = createSignal(true)
const [error, setError] = createSignal<string | null>(null)
let refreshSeq = 0
let lastUpdateAt = 0
// Validate that a response has the expected Config shape (must be a non-array object)
function isValidConfig(data: unknown): data is Config {
return !!data && typeof data === "object" && !Array.isArray(data)
}
async function refresh() {
const seq = ++refreshSeq
setLoading(true)
setError(null)
const errors: string[] = []
// Only fetch project config when a directory is set; otherwise treat as empty
if (sdk.directory) {
try {
const projRes = await sdk.client.config.get()
if (seq !== refreshSeq) return // superseded by newer refresh
const projData = projRes?.data
if (projData && !isValidConfig(projData)) {
console.error("[Config] Unexpected project config response shape:", projData)
}
setProject(reconcile(isValidConfig(projData) ? projData : {}))
} catch (e) {
console.error("[Config] Failed to fetch project config:", e)
if (seq !== refreshSeq) return
setProject(reconcile({}))
errors.push("project")
}
} else {
setProject(reconcile({}))
}
try {
const globalRes = await sdk.client.global.config.get()
if (seq !== refreshSeq) return
const globalData = globalRes?.data
if (globalData && !isValidConfig(globalData)) {
console.error("[Config] Unexpected global config response shape:", globalData)
}
setGlobal(reconcile(isValidConfig(globalData) ? globalData : {}))
} catch (e) {
console.error("[Config] Failed to fetch global config:", e)
if (seq !== refreshSeq) return
setGlobal(reconcile({}))
errors.push("global")
}
if (errors.length > 0) {
setError(`Failed to load ${errors.join(" and ")} configuration`)
}
setInitialLoading(false)
setLoading(false)
}
async function updateProject(patch: Config): Promise<Config | null> {
setError(null)
try {
const res = await sdk.client.config.update({ config: patch })
const data = res.data
if (data && !isValidConfig(data)) {
console.error("[Config] Unexpected project update response shape:", data)
return null
}
if (isValidConfig(data)) {
lastUpdateAt = Date.now()
setProject(reconcile(data))
return data
}
return null
} catch (e) {
console.error("[Config] Failed to update project config:", e)
setError("Failed to save project configuration")
return null
}
}
async function updateGlobal(patch: Config): Promise<Config | null> {
setError(null)
try {
// Preserve disabled_providers in the patch if it exists in the current
// global config but not in the patch itself. This prevents the backend
// from dropping it during a shallow merge (e.g. when saving MCP or
// provider settings from the UI).
const safePatch = global.disabled_providers && !patch.disabled_providers
? { ...patch, disabled_providers: global.disabled_providers }
: patch
const res = await sdk.client.global.config.update({ config: safePatch })
const data = res.data
if (data && !isValidConfig(data)) {
console.error("[Config] Unexpected global update response shape:", data)
return null
}
if (isValidConfig(data)) {
lastUpdateAt = Date.now()
setGlobal(reconcile(data))
return data
}
return null
} catch (e) {
console.error("[Config] Failed to update global config:", e)
setError("Failed to save global configuration")
return null
}
}
let refreshTimer: number | undefined
onMount(() => {
refresh()
})
// Refresh config when server reconnects (e.g. after config file changes).
// Skip if we just did an API update — our response already has the latest data
// and re-fetching risks returning stale data from a restarting server.
const unsub = events.subscribe((event) => {
if (event.type === "server.connected") {
if (Date.now() - lastUpdateAt < 5000) return
if (refreshTimer !== undefined) clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
refresh()
}, 500)
}
})
onCleanup(() => {
unsub()
if (refreshTimer !== undefined) clearTimeout(refreshTimer)
})
return (
<ConfigContext.Provider
value={{
project,
global,
loading,
initialLoading,
error,
updateProject,
updateGlobal,
refresh,
}}
>
{props.children}
</ConfigContext.Provider>
)
}
export function useConfig() {
const ctx = useContext(ConfigContext)
if (!ctx) throw new Error("useConfig must be used within ConfigProvider")
return ctx
}
// ── Helper types re-exported for convenience ──
export type { Config, PermissionConfig, PermissionActionConfig, PermissionRuleConfig }