diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 389bf9192510..3542a52fedab 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -92,6 +92,11 @@ export const LegacyStatus = Schema.Struct({ status: Schema.Literals(["added", "deleted", "modified"]), }).annotate({ identifier: "File" }) +export const Root = Schema.Struct({ + path: Schema.String, + label: Schema.String, +}).annotate({ identifier: "FileRoot" }) + export const FilePaths = { findText: "/find", findFile: "/find/file", @@ -99,6 +104,7 @@ export const FilePaths = { list: "/file", content: "/file/content", status: "/file/status", + roots: "/file/roots", } as const export const FileApi = HttpApi.make("file") @@ -165,6 +171,16 @@ export const FileApi = HttpApi.make("file") description: "Get the git status of all files in the project.", }), ), + HttpApiEndpoint.get("roots", FilePaths.roots, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(Root), "Filesystem roots"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.roots", + summary: "List filesystem roots", + description: "List the machine's mounted drives, filesystem roots, and home directory for browsing.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457d4..b6643cac3a3c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -7,6 +7,7 @@ import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Effect, Layer, Option } from "effect" import ignore from "ignore" +import os from "os" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" @@ -128,6 +129,45 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return [] }) + const roots = Effect.fn("FileHttpApi.roots")(function* () { + const raw = yield* FSUtil.Service + const home = os.homedir() + if (process.platform === "win32") { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") + const drives = yield* Effect.all( + letters.map((letter) => + raw + .existsSafe(`${letter}:\\`) + .pipe(Effect.map((exists) => (exists ? { path: `${letter}:\\`, label: `${letter}:` } : undefined))), + ), + ) + return [ + ...drives.filter((drive): drive is { path: string; label: string } => drive !== undefined), + { path: home, label: "Home" }, + ] + } + const mountBases = ["/mnt", "/media", "/Volumes"] + const mounts = yield* Effect.all( + mountBases.map((base) => + raw.existsSafe(base).pipe( + Effect.flatMap((exists) => + exists + ? raw.readDirectoryEntries(base).pipe( + Effect.map((entries) => + entries + .filter((entry) => entry.type === "directory") + .map((entry) => ({ path: path.join(base, entry.name), label: entry.name })), + ), + Effect.catch(() => Effect.succeed([] as { path: string; label: string }[])), + ) + : Effect.succeed([] as { path: string; label: string }[]), + ), + ), + ), + ) + return [{ path: "/", label: "/" }, { path: home, label: "Home" }, ...mounts.flat()] + }) + return handlers .handle("findText", findText) .handle("findFile", findFile) @@ -135,5 +175,6 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("list", list) .handle("content", content) .handle("status", status) + .handle("roots", roots) }), ).pipe(Layer.provide(LocationServiceMap.layer)) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 55377f847712..9681da832dde 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -70,4 +70,21 @@ describe("file HttpApi", () => { expect(symbols.status).toBe(200) expect(await symbols.json()).toEqual([]) }) + + test("serves roots endpoint", async () => { + await using tmp = await tmpdir({ git: true }) + + const roots = await request(FilePaths.roots, tmp.path) + + expect(roots.status).toBe(200) + const body = await roots.json() + expect(Array.isArray(body)).toBe(true) + for (const root of body) { + expect(typeof root.path).toBe("string") + expect(typeof root.label).toBe("string") + } + if (process.platform !== "win32") { + expect(body).toContainEqual(expect.objectContaining({ path: "/" })) + } + }) })