Skip to content

Commit 0a8b629

Browse files
authored
refactor(tui): move config cache to InstanceState (#22378)
1 parent f40209b commit 0a8b629

2 files changed

Lines changed: 104 additions & 33 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Effect loose ends
2+
3+
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
4+
5+
## Config / TUI
6+
7+
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
8+
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
9+
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
10+
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
11+
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
12+
13+
## ConfigPaths
14+
15+
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
16+
Keep `fileInDirectory(...)` as a plain function.
17+
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
18+
Initial service surface should cover:
19+
- `projectFiles(...)`
20+
- `directories(...)`
21+
- `readFile(...)`
22+
- `parseText(...)`
23+
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
24+
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
25+
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
26+
27+
## Instance cleanup
28+
29+
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
30+
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
31+
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
32+
33+
## Notes
34+
35+
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
36+
- When changing config loading internals, rerun the config and TUI suites first before broad package sweeps.

packages/opencode/src/config/tui.ts

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { existsSync } from "fs"
22
import z from "zod"
33
import { mergeDeep, unique } from "remeda"
4+
import { Context, Effect, Fiber, Layer } from "effect"
45
import { Config } from "./config"
56
import { ConfigPaths } from "./paths"
67
import { migrateTuiConfig } from "./tui-migrate"
78
import { TuiInfo } from "./tui-schema"
8-
import { Instance } from "@/project/instance"
99
import { Flag } from "@/flag/flag"
1010
import { Log } from "@/util/log"
1111
import { isRecord } from "@/util/record"
1212
import { Global } from "@/global"
13-
import { AppRuntime } from "@/effect/app-runtime"
13+
import { Filesystem } from "@/util/filesystem"
14+
import { InstanceState } from "@/effect/instance-state"
15+
import { makeRuntime } from "@/effect/run-service"
16+
import { AppFileSystem } from "@/filesystem"
1417

1518
export namespace TuiConfig {
1619
const log = Log.create({ service: "tui.config" })
@@ -21,13 +24,26 @@ export namespace TuiConfig {
2124
result: Info
2225
}
2326

27+
type State = {
28+
config: Info
29+
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
30+
}
31+
2432
export type Info = z.output<typeof Info> & {
2533
// Internal resolved plugin list used by runtime loading.
2634
plugin_origins?: Config.PluginOrigin[]
2735
}
2836

29-
function pluginScope(file: string): Config.PluginScope {
30-
if (Instance.containsPath(file)) return "local"
37+
export interface Interface {
38+
readonly get: () => Effect.Effect<Info>
39+
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
40+
}
41+
42+
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
43+
44+
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
45+
if (Filesystem.contains(ctx.directory, file)) return "local"
46+
if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
3147
return "global"
3248
}
3349

@@ -51,16 +67,12 @@ export namespace TuiConfig {
5167
}
5268
}
5369

54-
function installDeps(dir: string): Promise<void> {
55-
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
56-
}
57-
58-
async function mergeFile(acc: Acc, file: string) {
70+
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
5971
const data = await loadFile(file)
6072
acc.result = mergeDeep(acc.result, data)
6173
if (!data.plugin?.length) return
6274

63-
const scope = pluginScope(file)
75+
const scope = pluginScope(file, ctx)
6476
const plugins = Config.deduplicatePluginOrigins([
6577
...(acc.result.plugin_origins ?? []),
6678
...data.plugin.map((spec) => ({ spec, scope, source: file })),
@@ -69,46 +81,48 @@ export namespace TuiConfig {
6981
acc.result.plugin_origins = plugins
7082
}
7183

72-
const state = Instance.state(async () => {
84+
async function loadState(ctx: { directory: string; worktree: string }) {
7385
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
7486
? []
75-
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
76-
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
87+
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
88+
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
7789
const custom = customPath()
7890
const managed = Config.managedConfigDir()
7991
await migrateTuiConfig({ directories, custom, managed })
8092
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
8193
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
8294
? []
83-
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
95+
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
8496

8597
const acc: Acc = {
8698
result: {},
8799
}
88100

89101
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
90-
await mergeFile(acc, file)
102+
await mergeFile(acc, file, ctx)
91103
}
92104

93105
if (custom) {
94-
await mergeFile(acc, custom)
106+
await mergeFile(acc, custom, ctx)
95107
log.debug("loaded custom tui config", { path: custom })
96108
}
97109

98110
for (const file of projectFiles) {
99-
await mergeFile(acc, file)
111+
await mergeFile(acc, file, ctx)
100112
}
101113

102-
for (const dir of unique(directories)) {
114+
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
115+
116+
for (const dir of dirs) {
103117
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
104118
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
105-
await mergeFile(acc, file)
119+
await mergeFile(acc, file, ctx)
106120
}
107121
}
108122

109123
if (existsSync(managed)) {
110124
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
111-
await mergeFile(acc, file)
125+
await mergeFile(acc, file, ctx)
112126
}
113127
}
114128

@@ -122,27 +136,48 @@ export namespace TuiConfig {
122136
}
123137
acc.result.keybinds = Config.Keybinds.parse(keybinds)
124138

125-
const deps: Promise<void>[] = []
126-
if (acc.result.plugin?.length) {
127-
for (const dir of unique(directories)) {
128-
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
129-
deps.push(installDeps(dir))
130-
}
131-
}
132-
133139
return {
134140
config: acc.result,
135-
deps,
141+
dirs: acc.result.plugin?.length ? dirs : [],
136142
}
137-
})
143+
}
144+
145+
export const layer = Layer.effect(
146+
Service,
147+
Effect.gen(function* () {
148+
const cfg = yield* Config.Service
149+
const state = yield* InstanceState.make<State>(
150+
Effect.fn("TuiConfig.state")(function* (ctx) {
151+
const data = yield* Effect.promise(() => loadState(ctx))
152+
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
153+
concurrency: "unbounded",
154+
})
155+
return { config: data.config, deps }
156+
}),
157+
)
158+
159+
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
160+
161+
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
162+
InstanceState.useEffect(state, (s) =>
163+
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
164+
),
165+
)
166+
167+
return Service.of({ get, waitForDependencies })
168+
}),
169+
)
170+
171+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
172+
173+
const { runPromise } = makeRuntime(Service, defaultLayer)
138174

139175
export async function get() {
140-
return state().then((x) => x.config)
176+
return runPromise((svc) => svc.get())
141177
}
142178

143179
export async function waitForDependencies() {
144-
const deps = await state().then((x) => x.deps)
145-
await Promise.all(deps)
180+
await runPromise((svc) => svc.waitForDependencies())
146181
}
147182

148183
async function loadFile(filepath: string): Promise<Info> {

0 commit comments

Comments
 (0)