wip(app): settings
This commit is contained in:
@@ -255,7 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
editorRef.focus()
|
|
||||||
if (params.id) return
|
if (params.id) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||||
|
|||||||
@@ -1,12 +1,153 @@
|
|||||||
import { Component } from "solid-js"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
|
||||||
|
type PermissionAction = "allow" | "ask" | "deny"
|
||||||
|
|
||||||
|
type PermissionObject = Record<string, PermissionAction>
|
||||||
|
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||||
|
type PermissionMap = Record<string, PermissionValue>
|
||||||
|
|
||||||
|
type PermissionItem = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS: Array<{ value: PermissionAction; label: string }> = [
|
||||||
|
{ value: "allow", label: "Allow" },
|
||||||
|
{ value: "ask", label: "Ask" },
|
||||||
|
{ value: "deny", label: "Deny" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ITEMS: PermissionItem[] = [
|
||||||
|
{ id: "read", title: "Read", description: "Reading a file (matches the file path)" },
|
||||||
|
{ id: "edit", title: "Edit", description: "Modify files, including edits, writes, patches, and multi-edits" },
|
||||||
|
{ id: "glob", title: "Glob", description: "Match files using glob patterns" },
|
||||||
|
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions" },
|
||||||
|
{ id: "list", title: "List", description: "List files within a directory" },
|
||||||
|
{ id: "bash", title: "Bash", description: "Run shell commands" },
|
||||||
|
{ id: "task", title: "Task", description: "Launch sub-agents" },
|
||||||
|
{ id: "skill", title: "Skill", description: "Load a skill by name" },
|
||||||
|
{ id: "lsp", title: "LSP", description: "Run language server queries" },
|
||||||
|
{ id: "todoread", title: "Todo Read", description: "Read the todo list" },
|
||||||
|
{ id: "todowrite", title: "Todo Write", description: "Update the todo list" },
|
||||||
|
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL" },
|
||||||
|
{ id: "websearch", title: "Web Search", description: "Search the web" },
|
||||||
|
{ id: "codesearch", title: "Code Search", description: "Search code on the web" },
|
||||||
|
{ id: "external_directory", title: "External Directory", description: "Access files outside the project directory" },
|
||||||
|
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||||
|
|
||||||
|
function toMap(value: unknown): PermissionMap {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||||
|
|
||||||
|
const action = getAction(value)
|
||||||
|
if (action) return { "*": action }
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAction(value: unknown): PermissionAction | undefined {
|
||||||
|
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||||
|
const action = getAction(value)
|
||||||
|
if (action) return action
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||||
|
|
||||||
|
return getAction((value as Record<string, unknown>)["*"])
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsPermissions: Component = () => {
|
export const SettingsPermissions: Component = () => {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
|
const permission = createMemo(() => {
|
||||||
|
return toMap(globalSync.data.config.permission)
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionFor = (id: string): PermissionAction => {
|
||||||
|
const value = permission()[id]
|
||||||
|
const direct = getRuleDefault(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const wildcard = getRuleDefault(permission()["*"])
|
||||||
|
if (wildcard) return wildcard
|
||||||
|
|
||||||
|
return "allow"
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPermission = async (id: string, action: PermissionAction) => {
|
||||||
|
const before = globalSync.data.config.permission
|
||||||
|
const map = toMap(before)
|
||||||
|
const existing = map[id]
|
||||||
|
|
||||||
|
const nextValue =
|
||||||
|
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||||
|
|
||||||
|
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||||
|
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
|
||||||
|
globalSync.set("config", "permission", before)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: "Failed to update permissions", description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
<div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base">
|
||||||
<h2 class="text-16-medium text-text-strong">Permissions</h2>
|
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
|
||||||
<p class="text-14-regular text-text-weak">Permission settings will be configurable here.</p>
|
<h2 class="text-16-medium text-text-strong">Permissions</h2>
|
||||||
|
<p class="text-14-regular text-text-weak">Control what tools the server can use by default.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-14-medium text-text-strong">Appearance</h3>
|
||||||
|
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||||
|
<For each={ITEMS}>
|
||||||
|
{(item) => (
|
||||||
|
<SettingsRow title={item.title} description={item.description}>
|
||||||
|
<Select
|
||||||
|
options={ACTIONS}
|
||||||
|
current={ACTIONS.find((o) => o.value === actionFor(item.id))}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SettingsRowProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||||
|
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,12 +101,29 @@ function createGlobalSync() {
|
|||||||
project: Project[]
|
project: Project[]
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
|
config: Config
|
||||||
|
reload: undefined | "pending" | "complete"
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
project: [],
|
project: [],
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
|
config: {},
|
||||||
|
reload: undefined,
|
||||||
|
})
|
||||||
|
let bootstrapQueue: string[] = []
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
if (globalStore.reload !== "complete") return
|
||||||
|
if (bootstrapQueue.length) {
|
||||||
|
for (const directory of bootstrapQueue) {
|
||||||
|
bootstrapInstance(directory)
|
||||||
|
}
|
||||||
|
bootstrap()
|
||||||
|
}
|
||||||
|
bootstrapQueue = []
|
||||||
|
setGlobalStore("reload", undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
@@ -205,6 +222,8 @@ function createGlobalSync() {
|
|||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setStore("status", "loading")
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!cache.ready()) return
|
if (!cache.ready()) return
|
||||||
const cached = cache.store.value
|
const cached = cache.store.value
|
||||||
@@ -230,91 +249,99 @@ function createGlobalSync() {
|
|||||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||||
}
|
}
|
||||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
|
||||||
.then(() => {
|
|
||||||
if (store.status !== "complete") setStore("status", "partial")
|
|
||||||
// non-blocking
|
|
||||||
Promise.all([
|
|
||||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
|
||||||
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
|
||||||
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
|
||||||
loadSessions(directory),
|
|
||||||
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
|
||||||
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
|
||||||
sdk.vcs.get().then((x) => {
|
|
||||||
const next = x.data ?? store.vcs
|
|
||||||
setStore("vcs", next)
|
|
||||||
if (next?.branch) cache.setStore("value", next)
|
|
||||||
}),
|
|
||||||
sdk.permission.list().then((x) => {
|
|
||||||
const grouped: Record<string, PermissionRequest[]> = {}
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
if (!perm?.id || !perm.sessionID) continue
|
|
||||||
const existing = grouped[perm.sessionID]
|
|
||||||
if (existing) {
|
|
||||||
existing.push(perm)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
grouped[perm.sessionID] = [perm]
|
|
||||||
}
|
|
||||||
|
|
||||||
batch(() => {
|
try {
|
||||||
for (const sessionID of Object.keys(store.permission)) {
|
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||||
if (grouped[sessionID]) continue
|
} catch (err) {
|
||||||
setStore("permission", sessionID, [])
|
console.error("Failed to bootstrap instance", err)
|
||||||
}
|
const project = getFilename(directory)
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
setStore(
|
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||||
"permission",
|
setStore("status", "partial")
|
||||||
sessionID,
|
return
|
||||||
reconcile(
|
}
|
||||||
permissions
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
sdk.question.list().then((x) => {
|
|
||||||
const grouped: Record<string, QuestionRequest[]> = {}
|
|
||||||
for (const question of x.data ?? []) {
|
|
||||||
if (!question?.id || !question.sessionID) continue
|
|
||||||
const existing = grouped[question.sessionID]
|
|
||||||
if (existing) {
|
|
||||||
existing.push(question)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
grouped[question.sessionID] = [question]
|
|
||||||
}
|
|
||||||
|
|
||||||
batch(() => {
|
if (store.status !== "complete") setStore("status", "partial")
|
||||||
for (const sessionID of Object.keys(store.question)) {
|
|
||||||
if (grouped[sessionID]) continue
|
Promise.all([
|
||||||
setStore("question", sessionID, [])
|
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||||
}
|
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
setStore(
|
loadSessions(directory),
|
||||||
"question",
|
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||||
sessionID,
|
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||||
reconcile(
|
sdk.vcs.get().then((x) => {
|
||||||
questions
|
const next = x.data ?? store.vcs
|
||||||
.filter((q) => !!q?.id)
|
setStore("vcs", next)
|
||||||
.slice()
|
if (next?.branch) cache.setStore("value", next)
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
}),
|
||||||
{ key: "id" },
|
sdk.permission.list().then((x) => {
|
||||||
),
|
const grouped: Record<string, PermissionRequest[]> = {}
|
||||||
)
|
for (const perm of x.data ?? []) {
|
||||||
}
|
if (!perm?.id || !perm.sessionID) continue
|
||||||
})
|
const existing = grouped[perm.sessionID]
|
||||||
}),
|
if (existing) {
|
||||||
]).then(() => {
|
existing.push(perm)
|
||||||
setStore("status", "complete")
|
continue
|
||||||
|
}
|
||||||
|
grouped[perm.sessionID] = [perm]
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
.catch((e) => setGlobalStore("error", e))
|
sdk.question.list().then((x) => {
|
||||||
|
const grouped: Record<string, QuestionRequest[]> = {}
|
||||||
|
for (const question of x.data ?? []) {
|
||||||
|
if (!question?.id || !question.sessionID) continue
|
||||||
|
const existing = grouped[question.sessionID]
|
||||||
|
if (existing) {
|
||||||
|
existing.push(question)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped[question.sessionID] = [question]
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions
|
||||||
|
.filter((q) => !!q?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
]).then(() => {
|
||||||
|
setStore("status", "complete")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
@@ -324,6 +351,7 @@ function createGlobalSync() {
|
|||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
switch (event?.type) {
|
switch (event?.type) {
|
||||||
case "global.disposed": {
|
case "global.disposed": {
|
||||||
|
if (globalStore.reload) return
|
||||||
bootstrap()
|
bootstrap()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -345,9 +373,16 @@ function createGlobalSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const [store, setStore] = child(directory)
|
const existing = children[directory]
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
const [store, setStore] = existing
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed": {
|
case "server.instance.disposed": {
|
||||||
|
if (globalStore.reload) {
|
||||||
|
bootstrapQueue.push(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
bootstrapInstance(directory)
|
bootstrapInstance(directory)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -591,6 +626,11 @@ function createGlobalSync() {
|
|||||||
setGlobalStore("path", x.data!)
|
setGlobalStore("path", x.data!)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
retry(() =>
|
||||||
|
globalSDK.client.global.configGet().then((x) => {
|
||||||
|
setGlobalStore("config", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.project.list().then(async (x) => {
|
globalSDK.client.project.list().then(async (x) => {
|
||||||
const projects = (x.data ?? [])
|
const projects = (x.data ?? [])
|
||||||
@@ -631,6 +671,7 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
|
set: setGlobalStore,
|
||||||
get ready() {
|
get ready() {
|
||||||
return globalStore.ready
|
return globalStore.ready
|
||||||
},
|
},
|
||||||
@@ -639,6 +680,14 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
child,
|
child,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
|
updateConfig: async (config: Config) => {
|
||||||
|
setGlobalStore("reload", "pending")
|
||||||
|
const response = await globalSDK.client.global.configUpdate({ config })
|
||||||
|
setTimeout(() => {
|
||||||
|
setGlobalStore("reload", "complete")
|
||||||
|
}, 1000)
|
||||||
|
return response
|
||||||
|
},
|
||||||
project: {
|
project: {
|
||||||
loadSessions,
|
loadSessions,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import { lazy } from "../util/lazy"
|
|||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
import {
|
||||||
|
type ParseError as JsoncParseError,
|
||||||
|
applyEdits,
|
||||||
|
modify,
|
||||||
|
parse as parseJsonc,
|
||||||
|
printParseErrorCode,
|
||||||
|
} from "jsonc-parser"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { LSPServer } from "../lsp/server"
|
import { LSPServer } from "../lsp/server"
|
||||||
import { BunProc } from "@/bun"
|
import { BunProc } from "@/bun"
|
||||||
@@ -20,6 +26,8 @@ import { Installation } from "@/installation"
|
|||||||
import { ConfigMarkdown } from "./markdown"
|
import { ConfigMarkdown } from "./markdown"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { Event } from "../server/event"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -1242,6 +1250,10 @@ export namespace Config {
|
|||||||
return state().then((x) => x.config)
|
return state().then((x) => x.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGlobal() {
|
||||||
|
return global()
|
||||||
|
}
|
||||||
|
|
||||||
export async function update(config: Info) {
|
export async function update(config: Info) {
|
||||||
const filepath = path.join(Instance.directory, "config.json")
|
const filepath = path.join(Instance.directory, "config.json")
|
||||||
const existing = await loadFile(filepath)
|
const existing = await loadFile(filepath)
|
||||||
@@ -1249,6 +1261,100 @@ export namespace Config {
|
|||||||
await Instance.dispose()
|
await Instance.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function globalConfigFile() {
|
||||||
|
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||||
|
path.join(Global.Path.config, file),
|
||||||
|
)
|
||||||
|
for (const file of candidates) {
|
||||||
|
if (existsSync(file)) return file
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||||
|
if (!isRecord(patch)) {
|
||||||
|
const edits = modify(input, path, patch, {
|
||||||
|
formattingOptions: {
|
||||||
|
insertSpaces: true,
|
||||||
|
tabSize: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return applyEdits(input, edits)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(patch).reduce((result, [key, value]) => {
|
||||||
|
if (value === undefined) return result
|
||||||
|
return patchJsonc(result, value, [...path, key])
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(text: string, filepath: string): Info {
|
||||||
|
const errors: JsoncParseError[] = []
|
||||||
|
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||||
|
if (errors.length) {
|
||||||
|
const lines = text.split("\n")
|
||||||
|
const errorDetails = errors
|
||||||
|
.map((e) => {
|
||||||
|
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||||
|
const line = beforeOffset.length
|
||||||
|
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||||
|
const problemLine = lines[line - 1]
|
||||||
|
|
||||||
|
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||||
|
if (!problemLine) return error
|
||||||
|
|
||||||
|
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
throw new JsonError({
|
||||||
|
path: filepath,
|
||||||
|
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Info.safeParse(data)
|
||||||
|
if (parsed.success) return parsed.data
|
||||||
|
|
||||||
|
throw new InvalidError({
|
||||||
|
path: filepath,
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGlobal(config: Info) {
|
||||||
|
const filepath = globalConfigFile()
|
||||||
|
const before = await Bun.file(filepath)
|
||||||
|
.text()
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.code === "ENOENT") return "{}"
|
||||||
|
throw new JsonError({ path: filepath }, { cause: err })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!filepath.endsWith(".jsonc")) {
|
||||||
|
const existing = parseConfig(before, filepath)
|
||||||
|
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
|
||||||
|
} else {
|
||||||
|
const next = patchJsonc(before, config)
|
||||||
|
parseConfig(next, filepath)
|
||||||
|
await Bun.write(filepath, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
global.reset()
|
||||||
|
await Instance.disposeAll()
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
payload: {
|
||||||
|
type: Event.Disposed.type,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function directories() {
|
export async function directories() {
|
||||||
return state().then((x) => x.directories)
|
return state().then((x) => x.directories)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,16 @@ export namespace FileWatcher {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
const watcher = lazy(() => {
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||||
const binding = require(
|
try {
|
||||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
const binding = require(
|
||||||
)
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
)
|
||||||
|
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to load watcher binding", { error })
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const state = Instance.state(
|
const state = Instance.state(
|
||||||
@@ -54,6 +59,10 @@ export namespace FileWatcher {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
log.info("watcher backend", { platform: process.platform, backend })
|
log.info("watcher backend", { platform: process.platform, backend })
|
||||||
|
|
||||||
|
const w = watcher()
|
||||||
|
if (!w) return {}
|
||||||
|
|
||||||
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
||||||
if (err) return
|
if (err) return
|
||||||
for (const evt of evts) {
|
for (const evt of evts) {
|
||||||
@@ -67,7 +76,7 @@ export namespace FileWatcher {
|
|||||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
const pending = watcher().subscribe(Instance.directory, subscribe, {
|
const pending = w.subscribe(Instance.directory, subscribe, {
|
||||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||||
backend,
|
backend,
|
||||||
})
|
})
|
||||||
@@ -89,7 +98,7 @@ export namespace FileWatcher {
|
|||||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||||
const pending = watcher().subscribe(vcsDir, subscribe, {
|
const pending = w.subscribe(vcsDir, subscribe, {
|
||||||
ignore: ignoreList,
|
ignore: ignoreList,
|
||||||
backend,
|
backend,
|
||||||
})
|
})
|
||||||
|
|||||||
7
packages/opencode/src/server/event.ts
Normal file
7
packages/opencode/src/server/event.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Connected: BusEvent.define("server.connected", z.object({})),
|
||||||
|
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||||
|
}
|
||||||
@@ -54,11 +54,6 @@ export namespace Server {
|
|||||||
return _url ?? new URL("http://localhost:4096")
|
return _url ?? new URL("http://localhost:4096")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Connected: BusEvent.define("server.connected", z.object({})),
|
|
||||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
export const App: () => Hono = lazy(
|
export const App: () => Hono = lazy(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import type {
|
|||||||
FindSymbolsResponses,
|
FindSymbolsResponses,
|
||||||
FindTextResponses,
|
FindTextResponses,
|
||||||
FormatterStatusResponses,
|
FormatterStatusResponses,
|
||||||
|
GlobalConfigGetResponses,
|
||||||
|
GlobalConfigUpdateErrors,
|
||||||
|
GlobalConfigUpdateResponses,
|
||||||
GlobalDisposeResponses,
|
GlobalDisposeResponses,
|
||||||
GlobalEventResponses,
|
GlobalEventResponses,
|
||||||
GlobalHealthResponses,
|
GlobalHealthResponses,
|
||||||
@@ -249,6 +252,42 @@ export class Global extends HeyApiClient {
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get global configuration
|
||||||
|
*
|
||||||
|
* Retrieve the global OpenCode configuration settings and preferences.
|
||||||
|
*/
|
||||||
|
public configGet<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||||
|
return (options?.client ?? this.client).get<GlobalConfigGetResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/global/config",
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update global configuration
|
||||||
|
*
|
||||||
|
* Update global OpenCode configuration settings and preferences.
|
||||||
|
*/
|
||||||
|
public configUpdate<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
config?: Config2
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
|
||||||
|
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
|
||||||
|
url: "/global/config",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Project extends HeyApiClient {
|
export class Project extends HeyApiClient {
|
||||||
|
|||||||
@@ -2189,6 +2189,47 @@ export type GlobalDisposeResponses = {
|
|||||||
|
|
||||||
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
|
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
|
||||||
|
|
||||||
|
export type GlobalConfigGetData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: never
|
||||||
|
url: "/global/config"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalConfigGetResponses = {
|
||||||
|
/**
|
||||||
|
* Global config
|
||||||
|
*/
|
||||||
|
200: Config
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses]
|
||||||
|
|
||||||
|
export type GlobalConfigUpdateData = {
|
||||||
|
body?: Config
|
||||||
|
path?: never
|
||||||
|
query?: never
|
||||||
|
url: "/global/config"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalConfigUpdateErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors]
|
||||||
|
|
||||||
|
export type GlobalConfigUpdateResponses = {
|
||||||
|
/**
|
||||||
|
* Successfully updated global config
|
||||||
|
*/
|
||||||
|
200: Config
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses]
|
||||||
|
|
||||||
export type ProjectListData = {
|
export type ProjectListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
Reference in New Issue
Block a user