diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 0c0658e74337..f51ccef6d3c8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,5 +1,6 @@ import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" +import { existsSync } from "fs" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "@opencode-ai/shared/util/glob" @@ -40,7 +41,6 @@ import { useKV } from "./kv" import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" -import { Filesystem } from "@/util/filesystem" import { useTuiConfig } from "./tui-config" import { isRecord } from "@/util/record" import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" @@ -477,15 +477,19 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) async function getCustomThemes() { - const directories = [ - Global.Path.config, - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: process.cwd(), - }), - )), - ] + const ups = (start: string) => { + const out: string[] = [] + let dir = start + while (true) { + const next = path.join(dir, ".opencode") + if (existsSync(next)) out.push(next) + const parent = path.dirname(dir) + if (parent === dir) return out + dir = parent + } + } + + const directories = [Global.Path.config, ...ups(process.cwd())] const result: Record = {} for (const dir of directories) { @@ -496,7 +500,7 @@ async function getCustomThemes() { symlink: true, })) { const name = path.basename(item, ".json") - result[name] = await Filesystem.readJson(item) + result[name] = (await Bun.file(item).json()) as ThemeJson } } return result diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 884a774499c1..3e5ca2f4520d 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -2,30 +2,74 @@ import path from "path" import os from "os" import z from "zod" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { NamedError } from "@opencode-ai/shared/util/error" -import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Global } from "@/global" +import { AppRuntime } from "@/effect/app-runtime" + +async function withFs(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* fn(fs) + }), + ) +} + +function missing(err: unknown) { + if (typeof err !== "object" || err === null) return false + if ("code" in err && err.code === "ENOENT") return true + return ( + "reason" in err && + typeof err.reason === "object" && + err.reason !== null && + "_tag" in err.reason && + err.reason._tag === "NotFound" + ) +} export namespace ConfigPaths { export async function projectFiles(name: string, directory: string, worktree: string) { - return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) + return withFs( + Effect.fn("ConfigPaths.projectFiles")(function* (fs) { + const dirs = [directory] + let dir = directory + while (true) { + if (worktree === dir) break + const parent = path.dirname(dir) + if (parent === dir) break + dirs.push(parent) + dir = parent + } + + const out: string[] = [] + for (const dir of dirs.toReversed()) { + for (const target of [`${name}.json`, `${name}.jsonc`]) { + const file = path.join(dir, target) + if (yield* fs.existsSafe(file)) out.push(file) + } + } + return out + }), + ) } export async function directories(directory: string, worktree: string) { return [ Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? await Array.fromAsync( - Filesystem.up({ + ? await withFs((fs) => + fs.up({ targets: [".opencode"], start: directory, stop: worktree, }), ) : []), - ...(await Array.fromAsync( - Filesystem.up({ + ...(await withFs((fs) => + fs.up({ targets: [".opencode"], start: Global.Path.home, stop: Global.Path.home, @@ -58,8 +102,8 @@ export namespace ConfigPaths { /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return + return withFs((fs) => fs.readFileString(filepath)).catch((err: unknown) => { + if (missing(err)) return throw new JsonError({ path: filepath }, { cause: err }) }) } @@ -108,11 +152,11 @@ export namespace ConfigPaths { const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const fileContent = ( - await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { + await withFs((fs) => fs.readFileString(resolvedPath)).catch((error: unknown) => { if (missing === "empty") return "" const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { + if (missing(error)) { throw new InvalidError( { path: configSource, diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 6c17310ff88d..5a7cc9076e04 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,10 +1,33 @@ +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Npm } from "../npm" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" import { which } from "../util/which" +import { AppRuntime } from "@/effect/app-runtime" import { Flag } from "@/flag/flag" +async function withFs(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* fn(fs) + }), + ) +} + +async function find(target: string) { + return withFs((fs) => fs.findUp(target, Instance.directory, Instance.worktree)) +} + +async function readJson(file: string) { + return withFs((fs) => fs.readJson(file).pipe(Effect.map((json) => json as T))) +} + +async function readText(file: string) { + return withFs((fs) => fs.readFileString(file)) +} + export interface Info { name: string environment?: Record @@ -66,9 +89,9 @@ export const prettier: Info = { ".gql", ], async enabled() { - const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree) + const items = await find("package.json") for (const item of items) { - const json = await Filesystem.readJson<{ + const json = await readJson<{ dependencies?: Record devDependencies?: Record }>(item) @@ -89,9 +112,9 @@ export const oxfmt: Info = { extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], async enabled() { if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false - const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree) + const items = await find("package.json") for (const item of items) { - const json = await Filesystem.readJson<{ + const json = await readJson<{ dependencies?: Record devDependencies?: Record }>(item) @@ -140,7 +163,7 @@ export const biome: Info = { async enabled() { const configs = ["biome.json", "biome.jsonc"] for (const config of configs) { - const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) + const found = await find(config) if (found.length > 0) { const bin = await Npm.which("@biomejs/biome") if (bin) return [bin, "format", "--write", "$FILE"] @@ -164,7 +187,7 @@ export const clang: Info = { name: "clang-format", extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], async enabled() { - const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) + const items = await find(".clang-format") if (items.length > 0) { const match = which("clang-format") if (match) return [match, "-i", "$FILE"] @@ -190,10 +213,10 @@ export const ruff: Info = { if (!which("ruff")) return false const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] for (const config of configs) { - const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) + const found = await find(config) if (found.length > 0) { if (config === "pyproject.toml") { - const content = await Filesystem.readText(found[0]) + const content = await readText(found[0]) if (content.includes("[tool.ruff]")) return ["ruff", "format", "$FILE"] } else { return ["ruff", "format", "$FILE"] @@ -202,9 +225,9 @@ export const ruff: Info = { } const deps = ["requirements.txt", "pyproject.toml", "Pipfile"] for (const dep of deps) { - const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) + const found = await find(dep) if (found.length > 0) { - const content = await Filesystem.readText(found[0]) + const content = await readText(found[0]) if (content.includes("ruff")) return ["ruff", "format", "$FILE"] } } @@ -288,7 +311,7 @@ export const ocamlformat: Info = { extensions: [".ml", ".mli"], async enabled() { if (!which("ocamlformat")) return false - const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) + const items = await find(".ocamlformat") if (items.length > 0) return ["ocamlformat", "-i", "$FILE"] return false }, @@ -358,9 +381,9 @@ export const pint: Info = { name: "pint", extensions: [".php"], async enabled() { - const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) + const items = await find("composer.json") for (const item of items) { - const json = await Filesystem.readJson<{ + const json = await readJson<{ require?: Record "require-dev"?: Record }>(item)