diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 3f0ffd93b85a..063eb9925ab0 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -109,6 +109,7 @@ const Group = HttpApiGroup.make("mock") }), ) .add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json })) + .add(HttpApiEndpoint.get("browseList", "/api/browse/list", { query: Query, success: Json })) .add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json })) .add( HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 45bef80164e3..0322124a6322 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -308,6 +308,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( Effect.map((data) => ({ location: location(config), data })), ), + // The picker lists by absolute directory, so the stub's root branch serves every listing — + // matching how fs.list arrived with no path for the same call sites. + browseList: (ctx) => + Effect.promise(() => Promise.resolve(config.fileList?.(""))).pipe( + Effect.map((entries) => ({ directory: ctx.query.directory ?? config.directory, entries })), + ), fsFind: (ctx) => Effect.promise(() => Promise.resolve( diff --git a/packages/app/src/workspaces/selection/dialog.tsx b/packages/app/src/workspaces/selection/dialog.tsx index fdbcd1a990f8..43fef645ccd8 100644 --- a/packages/app/src/workspaces/selection/dialog.tsx +++ b/packages/app/src/workspaces/selection/dialog.tsx @@ -99,26 +99,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") if (!cleaned || (root() && typed === current)) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) - if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const base = pickerRoot(cleaned) || root() || start() - if (!base) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.api.file - .find({ - location: { directory: base }, - query: pickerFileSearchQuery(base, value, home()), - type: "file", - limit: 20, - }) - .then((result) => result.data) - .catch(() => []) - const results = [ - ...directories, - ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), - ] - return { - query: value, - items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8), - } + return { query: value, items: directories.slice(0, 5) } }) const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) @@ -132,10 +113,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.api.file - .list({ location: { directory: absolute } }) + return sdk.api.browse + .list({ directory: absolute }) .then((result) => - result.data.map((entry) => ({ + result.entries.map((entry) => ({ name: getFilename(entry.path.replace(/[\\/]+$/, "")), type: entry.type, })), @@ -183,11 +164,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { if (!match) return const value = displayPickerPath(match.absolute, input(), home()) setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) - if (match.type === "file") { - setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "") - setSuggestionsOpen(false) - setActiveSuggestion(-1) - } } function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) { diff --git a/packages/app/src/workspaces/selection/domain.test.ts b/packages/app/src/workspaces/selection/domain.test.ts index 37b33c448298..5f6844e940c0 100644 --- a/packages/app/src/workspaces/selection/domain.test.ts +++ b/packages/app/src/workspaces/selection/domain.test.ts @@ -58,7 +58,6 @@ test("includes files in file autocomplete while preserving directory navigation" test("centralizes file and directory selection policy", () => { const file = pickerMode("file", "/repo") - expect(file.includeFiles).toBeTrue() expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts") expect(file.selection("/repo", "src/")).toBeUndefined() expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts") @@ -67,7 +66,6 @@ test("centralizes file and directory selection policy", () => { expect(file.navigation("/tmp")).toBeUndefined() const directory = pickerMode("directory") - expect(directory.includeFiles).toBeFalse() expect(directory.selection("/repo", "src/")).toBe("/repo/src") expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos") expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo") @@ -134,12 +132,11 @@ test("resolves directory autocomplete from the current browser root", async () = const directories: string[] = [] const sdk = { api: { - file: { - find: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ data: [] }) + browse: { + list: (input: { directory?: string }) => { + directories.push(input.directory ?? "") + return Promise.resolve({ entries: [] }) }, - list: () => Promise.resolve({ data: [] }), }, }, } as unknown as Parameters[0]["sdk"] @@ -153,21 +150,7 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) -test("keeps indexed directory results for servers that support empty search", async () => { - const sdk = { - api: { - file: { - find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }), - list: () => Promise.reject(new Error("listing should not run when search returns results")), - }, - }, - } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) - - expect(await search("")).toEqual(["/home/luke/projects"]) -}) - -test("lists the default directory when empty search is unsupported", async () => { +test("lists immediate directory children for empty search", async () => { const calls: string[] = [] const directories = Array.from({ length: 60 }, (_, index) => ({ path: `project-${index}/`, @@ -175,12 +158,11 @@ test("lists the default directory when empty search is unsupported", async () => })) const sdk = { api: { - file: { - find: () => Promise.resolve({ data: [] }), - list: (input: { location?: { directory?: string } }) => { - calls.push(input.location?.directory ?? "") + browse: { + list: (input: { directory?: string }) => { + calls.push(input.directory ?? "") return Promise.resolve({ - data: [...directories, { path: "README.md", type: "file" }], + entries: [...directories, { path: "README.md", type: "file" }], }) }, }, @@ -194,14 +176,13 @@ test("lists the default directory when empty search is unsupported", async () => expect(calls).toEqual(["/home/luke"]) }) -test("matches the default directory listing when typed search is unsupported", async () => { +test("matches typed queries against immediate children", async () => { const sdk = { api: { - file: { - find: () => Promise.resolve({ data: [] }), + browse: { list: () => Promise.resolve({ - data: [ + entries: [ { path: "Documents/", type: "directory" }, { path: "Downloads/", type: "directory" }, ], @@ -218,11 +199,11 @@ test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { api: { - file: { - list: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") + browse: { + list: (input: { directory?: string }) => { + directories.push(input.directory ?? "") return Promise.resolve({ - data: [ + entries: [ { path: "Users/", type: "directory" }, { path: "tmp/", type: "directory" }, ], diff --git a/packages/app/src/workspaces/selection/domain.ts b/packages/app/src/workspaces/selection/domain.ts index 9a2c1e6ff475..f051131cd82e 100644 --- a/packages/app/src/workspaces/selection/domain.ts +++ b/packages/app/src/workspaces/selection/domain.ts @@ -24,7 +24,6 @@ export function pickerSearchEntries( export function pickerMode(mode: "directory" | "file", base?: string) { if (mode === "file") { return { - includeFiles: true, action: "file" as const, entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { return treeEntries(parent, nodes) @@ -42,7 +41,6 @@ export function pickerMode(mode: "directory" | "file", base?: string) { } } return { - includeFiles: false, action: "directory" as const, entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { return treeEntries( @@ -342,9 +340,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.api.file - .list({ location: { directory: key } }) - .then((result) => result.data) + const request = args.sdk.api.browse + .list({ directory: key }) + .then((result) => result.entries) .catch(() => []) .then((nodes) => nodes @@ -374,19 +372,11 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.api.file - .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) - .then((result) => result.data.map((entry) => entry.path)) - .catch(() => []) - if (!active()) return [] - if (results.length) { - return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) - } - const fallback = query + const results = query ? await match(input.directory, query, 50) : (await directories(input.directory)).map((item) => item.absolute) if (!active()) return [] - return fallback + return results } const segments = query.replace(/^\/+/, "").split("/") const head = segments.slice(0, -1).filter((part) => part && part !== ".") diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 4d0ef424a07c..dd7c9ddc065f 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1,11 +1,12 @@ // Generated by @opencode-ai/httpapi-codegen. Do not edit. import type { Effect, Stream } from "effect" +import type { AbsolutePath } from "@opencode-ai/schema/schema" +import type { FileSystem } from "@opencode-ai/schema/filesystem" import type { Location } from "@opencode-ai/schema/location" import type { Agent } from "@opencode-ai/schema/agent" import type { Plugin } from "@opencode-ai/schema/plugin" import type { Workspace } from "@opencode-ai/schema/workspace" import type { Session } from "@opencode-ai/schema/session" -import type { AbsolutePath } from "@opencode-ai/schema/schema" import type { Project } from "@opencode-ai/schema/project" import type { RelativePath } from "@opencode-ai/schema/schema" import type { Brand } from "effect" @@ -28,7 +29,6 @@ import type { Mcp } from "@opencode-ai/schema/mcp" import type { Credential } from "@opencode-ai/schema/credential" import type { Permission } from "@opencode-ai/schema/permission" import type { PermissionSaved } from "@opencode-ai/schema/permission-saved" -import type { FileSystem } from "@opencode-ai/schema/filesystem" import type { Command } from "@opencode-ai/schema/command" import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import type { Pty } from "@opencode-ai/schema/pty" @@ -54,6 +54,14 @@ export interface ServerApi { readonly get: ServerGetOperation } +export type BrowseListInput = { readonly directory: AbsolutePath } +export type BrowseListOutput = { readonly directory: AbsolutePath; readonly entries: ReadonlyArray } +export type BrowseListOperation = (input: BrowseListInput) => Effect.Effect + +export interface BrowseApi { + readonly list: BrowseListOperation +} + export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined } @@ -2085,6 +2093,7 @@ export interface ConfigApi { export interface AppApi { readonly health: HealthApi readonly server: ServerApi + readonly browse: BrowseApi readonly location: LocationApi readonly agent: AgentApi readonly plugin: PluginApi diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 6092f4546282..6d4343ae3dc0 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -7,6 +7,8 @@ import { ClientApi } from "../../contract" import type { HealthGetOutput, ServerGetOutput, + BrowseListInput, + BrowseListOutput, LocationGetInput, LocationGetOutput, AgentListInput, @@ -297,6 +299,13 @@ const EndpointServerGet = (raw: RawClient["server.server"]) => () => const adaptGroupServer = (raw: RawClient["server.server"]) => ({ get: EndpointServerGet(raw) }) +const EndpointBrowseList = (raw: RawClient["server.browse"]) => (input: BrowseListInput) => + preserveEffect()( + raw["browse.list"]({ query: { directory: input["directory"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const adaptGroupBrowse = (raw: RawClient["server.browse"]) => ({ list: EndpointBrowseList(raw) }) + const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) => preserveEffect()( raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), @@ -1582,6 +1591,7 @@ const adaptGroupConfig = (raw: RawClient["server.config"]) => ({ get: EndpointCo const adaptClient = (raw: RawClient) => ({ health: adaptGroupHealth(raw["server.health"]), server: adaptGroupServer(raw["server.server"]), + browse: adaptGroupBrowse(raw["server.browse"]), location: adaptGroupLocation(raw["server.location"]), agent: adaptGroupAgent(raw["server.agent"]), plugin: adaptGroupPlugin(raw["server.plugin"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index cd5f6433b2b3..3b8c4a89780b 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1,6 +1,8 @@ import type { HealthGetOutput, ServerGetOutput, + BrowseListInput, + BrowseListOutput, LocationGetInput, LocationGetOutput, AgentListInput, @@ -417,6 +419,20 @@ export function make(options: ClientOptions) { requestOptions, ), }, + browse: { + list: (input: BrowseListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/browse/list`, + query: { directory: input["directory"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, location: { get: (input?: LocationGetInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 5681377eda62..40cf71829c70 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2,6 +2,8 @@ export type JsonValue = null | boolean | number | string | Array | { export type ServiceHealth = { healthy: true; version: string; pid: number } +export type FileSystemEntry = { path: string; type: "file" | "directory" } + export type ModelRef = { id: string; providerID: string; variant?: string } export type ProviderSettings = { [x: string]: any } @@ -324,8 +326,6 @@ export type PermissionSource = { type: "tool"; messageID: string; id: string } export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string } -export type FileSystemEntry = { path: string; type: "file" | "directory" } - export type CommandInfo = { name: string; description?: string } export type SkillInfo = { @@ -441,6 +441,8 @@ export type WebSearchProvider = { id: string; name: string } export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } } +export type BrowseResult = { directory: string; entries: Array } + export type ProviderRequest = { settings: ProviderSettings headers: { [x: string]: string } @@ -2349,6 +2351,10 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError" +export type BrowseError = { readonly name: "BrowseError"; readonly data: { readonly message: string } } +export const isBrowseError = (value: unknown): value is BrowseError => + typeof value === "object" && value !== null && "name" in value && value["name"] === "BrowseError" + export type AgentNotFoundError = { readonly _tag: "AgentNotFoundError" readonly agentID: string @@ -2539,6 +2545,10 @@ export type HealthGetOutput = ServiceHealth export type ServerGetOutput = { urls: Array } +export type BrowseListInput = { readonly directory: { readonly directory: string }["directory"] } + +export type BrowseListOutput = BrowseResult + export type LocationGetInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 358918030f6d..1a280ab5dd5c 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -2,6 +2,7 @@ import { Context } from "effect" import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" import { SchemaErrorMiddleware } from "./middleware/schema-error.js" import { GenerateGroup } from "./groups/generate.js" +import { BrowseGroup } from "./groups/browse.js" import { MessageGroup } from "./groups/message.js" import { ModelGroup } from "./groups/model.js" import { ProviderGroup } from "./groups/provider.js" @@ -86,6 +87,7 @@ type ApiGroups< > = | typeof HealthGroup | typeof ServerGroup + | typeof BrowseGroup | typeof DebugGroup | typeof MigrationGroup | typeof WorktreeGroup @@ -153,6 +155,7 @@ const makeApiFromGroup = < HttpApi.make("server") .add(HealthGroup) .add(ServerGroup) + .add(BrowseGroup) .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 0417f872a539..a7bed36a2a31 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -35,6 +35,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({ export const groupNames = { "server.health": "health", "server.server": "server", + "server.browse": "browse", "server.debug": "debug", "server.migration": "migration", "server.location": "location", diff --git a/packages/protocol/src/groups/browse.ts b/packages/protocol/src/groups/browse.ts new file mode 100644 index 000000000000..edffc5c65409 --- /dev/null +++ b/packages/protocol/src/groups/browse.ts @@ -0,0 +1,45 @@ +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export class BrowseError extends Schema.Error("BrowseError")( + { + name: Schema.Literal("BrowseError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + +const ListQuery = Schema.Struct({ + directory: AbsolutePath, +}) + +const Result = Schema.Struct({ + directory: AbsolutePath, + entries: Schema.Array(FileSystem.Entry), +}).annotate({ identifier: "Browse.Result" }) + +export const BrowseGroup = HttpApiGroup.make("server.browse") + .add( + HttpApiEndpoint.get("browse.list", "/api/browse/list", { + query: ListQuery, + success: Result, + error: BrowseError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.browse.list", + summary: "Browse host directory", + description: "List direct children of one host directory. Pure navigation: never resolves or materializes a location runtime.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "browse", + description: + "Host filesystem navigation for picking locations. These routes never materialize location runtimes or start their services; use the location-scoped fs routes for work inside an active location.", + }), + ) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index e55bb2839450..a193593fd779 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -15,6 +15,7 @@ import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { HealthHandler } from "./handlers/health" import { ServerHandler } from "./handlers/server" +import { BrowseHandler } from "./handlers/browse" import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { PersistentPtyHandler } from "./handlers/persistent-pty" @@ -36,6 +37,7 @@ import { WorkspaceHandler } from "./handlers/workspace" export const handlers = Layer.mergeAll( HealthHandler, ServerHandler, + BrowseHandler, DebugHandler, MigrationHandler, LocationHandler, diff --git a/packages/server/src/handlers/browse.ts b/packages/server/src/handlers/browse.ts new file mode 100644 index 000000000000..1da7329a06f3 --- /dev/null +++ b/packages/server/src/handlers/browse.ts @@ -0,0 +1,50 @@ +import { BrowseError } from "@opencode-ai/protocol/groups/browse" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { RelativePath } from "@opencode-ai/schema/schema" +import { FSUtil } from "@opencode-ai/util/fs-util" +import path from "path" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +function failure(message: string) { + return new BrowseError({ name: "BrowseError", data: { message } }) +} + +function describe(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + return message.split("\n")[0] ?? message +} + +export const BrowseHandler = HttpApiBuilder.group(Api, "server.browse", (handlers) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + return handlers.handle("browse.list", (ctx) => + Effect.gen(function* () { + const directory = ctx.query.directory + const info = yield* fs.stat(directory).pipe( + Effect.mapError((error) => failure(`Cannot access ${directory}: ${describe(error)}`)), + ) + if (info.type !== "Directory") return yield* Effect.fail(failure(`Not a directory: ${directory}`)) + const items = yield* fs.readDirectoryEntries(directory).pipe( + Effect.mapError((error) => failure(`Cannot list ${directory}: ${describe(error)}`)), + ) + return { + directory, + entries: items + .flatMap((item) => + item.type === "file" || item.type === "directory" + ? [ + FileSystem.Entry.make({ + path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")), + type: item.type, + }), + ] + : [], + ) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), + } + }), + ) + }), +) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f301d394257c..ce76785755a3 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -21,6 +21,7 @@ import { ShellSelect } from "@opencode-ai/core/shell/select" import { Job } from "@opencode-ai/core/job" import { Mcp } from "@opencode-ai/core/mcp/index" import { Global } from "@opencode-ai/util/global" +import { FSUtil } from "@opencode-ai/util/fs-util" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationActivity } from "@opencode-ai/core/location-activity" @@ -50,6 +51,7 @@ import type { ServerOptions } from "./options" const applicationServiceNodes = [ Global.node, + FSUtil.node, Database.node, Bus.node, EventLogger.node, diff --git a/packages/server/test/browse.test.ts b/packages/server/test/browse.test.ts new file mode 100644 index 000000000000..8b12da24d907 --- /dev/null +++ b/packages/server/test/browse.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Effect } from "effect" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { startServer } from "./fixture/server" + +const setup = Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-browse-endpoint-"))) + const root = path.join(tmp.path, "root") + yield* Effect.promise(() => fs.mkdir(path.join(root, "child"), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(path.join(root, "file.txt"), "content")) + const server = yield* startServer(path.join(tmp.path, "config")) + return { root, server } +}) + +it.live("browse lists direct children of a host directory", () => + Effect.gen(function* () { + const { root, server } = yield* setup + const url = new URL("/api/browse/list", server.base) + url.searchParams.set("directory", root) + const response = yield* Effect.promise(() => fetch(url, { headers: server.headers })) + const listed = yield* Effect.promise(() => response.json()) + expect(response.status).toBe(200) + expect(listed.directory).toBe(root) + expect(listed.entries).toEqual([ + { path: `child${path.sep}`, type: "directory" }, + { path: "file.txt", type: "file" }, + ]) + }), +) + +it.live("browse rejects missing directories", () => + Effect.gen(function* () { + const { root, server } = yield* setup + const url = new URL("/api/browse/list", server.base) + url.searchParams.set("directory", path.join(root, "missing")) + const response = yield* Effect.promise(() => fetch(url, { headers: server.headers })) + expect(response.status).toBe(400) + }), +) + +it.live("browse rejects paths that are not directories", () => + Effect.gen(function* () { + const { root, server } = yield* setup + const url = new URL("/api/browse/list", server.base) + url.searchParams.set("directory", path.join(root, "file.txt")) + const response = yield* Effect.promise(() => fetch(url, { headers: server.headers })) + expect(response.status).toBe(400) + }), +) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index abd4e47f6f34..67e742696753 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -349,13 +349,18 @@ export function Autocomplete(props: { : undefined const requestLocation = { - directory: directorySearch?.directory ?? input.location?.directory, + directory: input.location?.directory, workspace: input.location?.workspaceID ?? data.location.default().workspaceID, } + // Directory completion is host navigation: browse lists one directory without materializing a location. const result = await ( input.visible === "directory" - ? client.api.file.list({ location: requestLocation }) - : client.api.file.find({ query: base, limit: 20, location: requestLocation }) + ? client.api.browse.list({ + directory: directorySearch?.directory ?? input.location?.directory ?? paths.cwd, + }) + : client.api.file + .find({ query: base, limit: 20, location: requestLocation }) + .then((result) => ({ directory: result.location.directory, entries: result.data })) ).then( (result) => result, () => undefined, @@ -376,17 +381,17 @@ export function Autocomplete(props: { value: exact, isDirectory: true, path: exact, - absolute: result.location.directory, + absolute: result.directory, onSelect: () => insertDirectory(exact), }) } const entries = input.visible === "directory" - ? result.data.filter( + ? result.entries.filter( (item) => item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""), ) - : result.data + : result.entries options.push( ...entries.map((item): AutocompleteOption => { if (input.visible === "directory") { @@ -396,11 +401,11 @@ export function Autocomplete(props: { value: directory, isDirectory: true, path: directory, - absolute: path.resolve(result.location.directory, item.path), + absolute: path.resolve(result.directory, item.path), onSelect: () => insertDirectory(directory), } } - const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) + const { filename, part } = createFilePart(item, path.join(result.directory, item.path), lineRange) return { display: Locale.truncateMiddle(filename, width), value: filename, diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index c29c424a8f13..068a9109f806 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -110,6 +110,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType