Skip to content

Commit ee069bf

Browse files
authored
Add global config file editor (NeuralNomadsAI#477)
## Summary - Add an allowlisted server API for editing global config files, starting with OpenCode global config. - Add a Config Files settings section using the existing Monaco editor with save, reload, dirty-state handling, and responsive layout. - Improve compact settings navigation with a full-width section selector, settings icon, and close action in the compact toolbar. ## Validation - npm run typecheck in packages/server - npm run typecheck in packages/ui - npm run build in packages/ui ## Notes - Existing Vite warnings about virtua JSX import source and large chunks remain unrelated.
1 parent 4ddd256 commit ee069bf

20 files changed

Lines changed: 994 additions & 15 deletions

packages/server/src/api-types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,26 @@ export interface FileSystemFileContentResponse {
220220
encoding: "utf-8" | "base64"
221221
}
222222

223+
export interface ConfigFileDescriptor {
224+
id: string
225+
label: string
226+
path: string
227+
language: string
228+
}
229+
230+
export type ConfigFileListResponse = ConfigFileDescriptor[]
231+
232+
export interface ConfigFileContentResponse {
233+
id: string
234+
path: string
235+
contents: string
236+
exists: boolean
237+
}
238+
239+
export interface ConfigFileContentRequest {
240+
contents: string
241+
}
242+
223243
export const WINDOWS_DRIVES_ROOT = "__drives__"
224244

225245
export interface WorkspaceFileResponse {

packages/server/src/server/http-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { EventBus } from "../events/bus"
1818
import { registerWorkspaceRoutes } from "./routes/workspaces"
1919
import { registerSettingsRoutes } from "./routes/settings"
2020
import { registerFilesystemRoutes } from "./routes/filesystem"
21+
import { registerConfigFileRoutes } from "./routes/config-files"
2122
import { registerMetaRoutes } from "./routes/meta"
2223
import { registerEventRoutes } from "./routes/events"
2324
import { registerStorageRoutes } from "./routes/storage"
@@ -271,6 +272,7 @@ export function createHttpServer(deps: HttpServerDeps) {
271272
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
272273
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
273274
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
275+
registerConfigFileRoutes(app)
274276
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
275277
registerEventRoutes(app, {
276278
eventBus: deps.eventBus,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import assert from "node:assert/strict"
2+
import fs from "node:fs"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import { afterEach, describe, it } from "node:test"
6+
import Fastify from "fastify"
7+
8+
import { registerConfigFileRoutes } from "./config-files"
9+
10+
const tempDirs = new Set<string>()
11+
12+
afterEach(() => {
13+
for (const dir of tempDirs) {
14+
fs.rmSync(dir, { recursive: true, force: true })
15+
}
16+
tempDirs.clear()
17+
})
18+
19+
describe("config file routes", () => {
20+
it("lists only allowlisted config file descriptors", async () => {
21+
const tempDir = createTempDir()
22+
const app = createApp([
23+
createConfigEntry(path.join(tempDir, "opencode", "opencode.json"), "~/.config/opencode/opencode.json"),
24+
createConfigEntry(path.join(tempDir, "opencode", "opencode.jsonc"), "~/.config/opencode/opencode.jsonc", {
25+
id: "test-config-jsonc",
26+
label: "Test Config JSONC",
27+
language: "jsonc",
28+
}),
29+
])
30+
31+
const response = await app.inject({ method: "GET", url: "/api/config-files" })
32+
33+
assert.equal(response.statusCode, 200)
34+
assert.deepEqual(response.json(), [
35+
{
36+
id: "test-config",
37+
label: "Test Config",
38+
path: "~/.config/opencode/opencode.json",
39+
language: "json",
40+
},
41+
{
42+
id: "test-config-jsonc",
43+
label: "Test Config JSONC",
44+
path: "~/.config/opencode/opencode.jsonc",
45+
language: "jsonc",
46+
},
47+
])
48+
await app.close()
49+
})
50+
51+
it("returns empty content for an allowlisted missing file", async () => {
52+
const tempDir = createTempDir()
53+
const app = createApp([createConfigEntry(path.join(tempDir, "missing", "config.json"), "display/config.json")])
54+
55+
const response = await app.inject({ method: "GET", url: "/api/config-files/test-config/content" })
56+
57+
assert.equal(response.statusCode, 200)
58+
assert.deepEqual(response.json(), {
59+
id: "test-config",
60+
path: "display/config.json",
61+
contents: "",
62+
exists: false,
63+
})
64+
await app.close()
65+
})
66+
67+
it("creates parent directories when writing an allowlisted file", async () => {
68+
const tempDir = createTempDir()
69+
const targetPath = path.join(tempDir, "nested", "opencode.json")
70+
const app = createApp([createConfigEntry(targetPath, "display/opencode.json")])
71+
72+
const response = await app.inject({
73+
method: "PUT",
74+
url: "/api/config-files/test-config/content",
75+
payload: { contents: '{"model":"test"}' },
76+
})
77+
78+
assert.equal(response.statusCode, 204)
79+
assert.equal(fs.readFileSync(targetPath, "utf-8"), '{"model":"test"}')
80+
await app.close()
81+
})
82+
83+
it("rejects unknown config file ids", async () => {
84+
const tempDir = createTempDir()
85+
const app = createApp([createConfigEntry(path.join(tempDir, "opencode.json"), "display/opencode.json")])
86+
87+
const readResponse = await app.inject({ method: "GET", url: "/api/config-files/unknown/content" })
88+
const writeResponse = await app.inject({
89+
method: "PUT",
90+
url: "/api/config-files/unknown/content",
91+
payload: { contents: "{}" },
92+
})
93+
94+
assert.equal(readResponse.statusCode, 404)
95+
assert.equal(writeResponse.statusCode, 404)
96+
await app.close()
97+
})
98+
99+
it("returns a client error when writes fail", async () => {
100+
const tempDir = createTempDir()
101+
const blockedParent = path.join(tempDir, "not-a-directory")
102+
fs.writeFileSync(blockedParent, "occupied")
103+
const app = createApp([createConfigEntry(path.join(blockedParent, "opencode.json"), "display/opencode.json")])
104+
105+
const response = await app.inject({
106+
method: "PUT",
107+
url: "/api/config-files/test-config/content",
108+
payload: { contents: "{}" },
109+
})
110+
111+
assert.equal(response.statusCode, 400)
112+
await app.close()
113+
})
114+
})
115+
116+
function createTempDir() {
117+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-config-files-test-"))
118+
tempDirs.add(dir)
119+
return dir
120+
}
121+
122+
function createConfigEntry(
123+
absolutePath: string,
124+
displayPath: string,
125+
overrides: Partial<{ id: string; label: string; language: string }> = {},
126+
) {
127+
return {
128+
id: overrides.id ?? "test-config",
129+
label: overrides.label ?? "Test Config",
130+
path: displayPath,
131+
absolutePath,
132+
language: overrides.language ?? "json",
133+
}
134+
}
135+
136+
function createApp(files: Array<ReturnType<typeof createConfigEntry>>) {
137+
const app = Fastify({ logger: false })
138+
registerConfigFileRoutes(app, { files })
139+
return app
140+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { FastifyInstance } from "fastify"
2+
import fs from "fs/promises"
3+
import os from "os"
4+
import path from "path"
5+
import { z } from "zod"
6+
import type { ConfigFileDescriptor } from "../../api-types"
7+
8+
type ConfigFileEntry = ConfigFileDescriptor & {
9+
absolutePath: string
10+
}
11+
12+
interface ConfigFileRouteOptions {
13+
files?: ConfigFileEntry[]
14+
}
15+
16+
const ConfigFileContentBodySchema = z.object({
17+
contents: z.string(),
18+
})
19+
20+
function resolveOpenCodeGlobalConfigPaths(): ConfigFileEntry[] {
21+
if (process.platform === "win32") {
22+
const basePath = path.join(os.homedir(), ".config", "opencode")
23+
return [
24+
{
25+
id: "opencode-global-config",
26+
label: "OpenCode Global Config",
27+
path: "%USERPROFILE%\\.config\\opencode\\opencode.json",
28+
absolutePath: path.join(basePath, "opencode.json"),
29+
language: "json",
30+
},
31+
{
32+
id: "opencode-global-config-jsonc",
33+
label: "OpenCode Global Config (JSONC)",
34+
path: "%USERPROFILE%\\.config\\opencode\\opencode.jsonc",
35+
absolutePath: path.join(basePath, "opencode.jsonc"),
36+
language: "jsonc",
37+
},
38+
]
39+
}
40+
41+
return [
42+
{
43+
id: "opencode-global-config",
44+
label: "OpenCode Global Config",
45+
path: "~/.config/opencode/opencode.json",
46+
absolutePath: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
47+
language: "json",
48+
},
49+
{
50+
id: "opencode-global-config-jsonc",
51+
label: "OpenCode Global Config (JSONC)",
52+
path: "~/.config/opencode/opencode.jsonc",
53+
absolutePath: path.join(os.homedir(), ".config", "opencode", "opencode.jsonc"),
54+
language: "jsonc",
55+
},
56+
]
57+
}
58+
59+
function defaultConfigFileEntries(): ConfigFileEntry[] {
60+
return resolveOpenCodeGlobalConfigPaths()
61+
}
62+
63+
function listConfigFiles(files: ConfigFileEntry[]): ConfigFileDescriptor[] {
64+
return files.map(({ absolutePath: _absolutePath, ...file }) => file)
65+
}
66+
67+
function getConfigFile(files: ConfigFileEntry[], id: string): ConfigFileEntry | null {
68+
return files.find((file) => file.id === id) ?? null
69+
}
70+
71+
export function registerConfigFileRoutes(app: FastifyInstance, options: ConfigFileRouteOptions = {}) {
72+
const configFiles = options.files ?? defaultConfigFileEntries()
73+
74+
app.get("/api/config-files", async () => listConfigFiles(configFiles))
75+
76+
app.get<{ Params: { id: string } }>("/api/config-files/:id/content", async (request, reply) => {
77+
const file = getConfigFile(configFiles, request.params.id)
78+
if (!file) {
79+
reply.code(404)
80+
return { error: "Config file not found" }
81+
}
82+
83+
try {
84+
const contents = await fs.readFile(file.absolutePath, "utf-8")
85+
return { id: file.id, path: file.path, contents, exists: true }
86+
} catch (error) {
87+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
88+
return { id: file.id, path: file.path, contents: "", exists: false }
89+
}
90+
91+
reply.code(400)
92+
return { error: error instanceof Error ? error.message : "Failed to read config file" }
93+
}
94+
})
95+
96+
app.put<{ Params: { id: string } }>("/api/config-files/:id/content", async (request, reply) => {
97+
const file = getConfigFile(configFiles, request.params.id)
98+
if (!file) {
99+
reply.code(404)
100+
return { error: "Config file not found" }
101+
}
102+
103+
try {
104+
const body = ConfigFileContentBodySchema.parse(request.body ?? {})
105+
await fs.mkdir(path.dirname(file.absolutePath), { recursive: true })
106+
await fs.writeFile(file.absolutePath, body.contents, "utf-8")
107+
reply.code(204)
108+
} catch (error) {
109+
reply.code(400)
110+
return { error: error instanceof Error ? error.message : "Failed to save config file" }
111+
}
112+
})
113+
}

packages/ui/src/components/file-viewer/monaco-file-viewer.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface MonacoFileViewerProps {
1010
path: string
1111
content: string
1212
wordWrap?: "on" | "off"
13+
compactGutter?: boolean
1314
onSave?: (content: string) => void
1415
onContentChange?: (content: string) => void
1516
}
@@ -41,6 +42,12 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
4142
props.onSave(editor.getValue())
4243
}
4344

45+
const lineNumbersMinChars = (value: string) => {
46+
if (!props.compactGutter) return 5
47+
const lineCount = value.split(/\r\n|\r|\n/).length
48+
return Math.max(3, String(lineCount).length + 1)
49+
}
50+
4451
onMount(() => {
4552
let cancelled = false
4653
void (async () => {
@@ -55,6 +62,10 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
5562
readOnly: false,
5663
automaticLayout: true,
5764
lineNumbers: "on",
65+
lineNumbersMinChars: lineNumbersMinChars(props.content),
66+
glyphMargin: false,
67+
folding: !props.compactGutter,
68+
lineDecorationsWidth: props.compactGutter ? 8 : 10,
5869
minimap: { enabled: false },
5970
scrollBeyondLastLine: false,
6071
wordWrap: "off",
@@ -90,6 +101,15 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
90101
editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" })
91102
})
92103

104+
createEffect(() => {
105+
if (!ready() || !editor) return
106+
editor.updateOptions({
107+
lineNumbersMinChars: lineNumbersMinChars(props.content),
108+
folding: !props.compactGutter,
109+
lineDecorationsWidth: props.compactGutter ? 8 : 10,
110+
})
111+
})
112+
93113
createEffect(() => {
94114
if (!ready() || !monaco || !editor) return
95115
const languageId = inferMonacoLanguageId(monaco, props.path)

0 commit comments

Comments
 (0)