From 0f6c270b4c4e96ff7f978c224d704f46398b19ed Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 07:33:44 +0800 Subject: [PATCH 1/7] fix(app): default project picker to home --- .../components/dialog-select-directory-v2.tsx | 12 +++++++--- .../components/dialog-select-directory.tsx | 12 +++++++--- .../src/context/global-sync/bootstrap.test.ts | 15 +++++++++--- .../app/src/context/global-sync/bootstrap.ts | 24 ++++++++++++++----- packages/app/src/context/server-sync.tsx | 2 +- packages/app/src/utils/location-path.ts | 14 +++++++++++ packages/client/src/generated/types.ts | 1 + packages/core/src/location.ts | 4 ++-- packages/protocol/src/groups/location.ts | 2 +- packages/schema/src/location.ts | 5 ++++ packages/schema/test/location.test.ts | 14 +++++++++++ packages/server/src/handlers/location.ts | 5 +++- 12 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/utils/location-path.ts create mode 100644 packages/schema/test/location.test.ts diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index f3376ad35f26..c08d85d3ddbd 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -9,6 +9,7 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import type { Path } from "@opencode-ai/sdk/v2/client" +import { locationPath } from "@/utils/location-path" import { absoluteTreePath, activeTreeNavigation, @@ -70,10 +71,15 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + if ((await sdk.protocol) === "v1") { + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + } + return sdk.api.location .get() - .then((result) => result.data) + .then(locationPath) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index fc248d821e29..d22310a778b2 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -10,6 +10,7 @@ import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import type { Path } from "@opencode-ai/sdk/v2/client" +import { locationPath } from "@/utils/location-path" interface DialogSelectDirectoryProps { title?: string @@ -63,10 +64,15 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingHome() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + if ((await sdk.protocol) === "v1") { + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + } + return sdk.api.location .get() - .then((result) => result.data) + .then(locationPath) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index a95706a3dbe5..428160601a0e 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -227,12 +227,21 @@ describe("config queries", () => { describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as Parameters[2] + const location = {} as Parameters[2] + const client = {} as Parameters[3] const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local - expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) - expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) + expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([ + "local", + "/repo", + "path", + ]) + expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([ + "https://debian.example", + "/repo", + "path", + ]) expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e47381649..963db6cfcf89 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -45,6 +45,7 @@ import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { normalizeSessionInfo } from "@/utils/session" import type { ServerProtocol } from "@/utils/server-protocol" import type { ServerApi } from "@/utils/server" +import { locationPath } from "@/utils/location-path" type GlobalStore = { ready: boolean @@ -119,6 +120,11 @@ type ProjectApi = { readonly current: (input?: ProjectCurrentInput) => Promise } +type LocationApi = { + readonly get: (input?: { + readonly location?: { readonly directory?: string; readonly workspace?: string } + }) => Promise +} type McpApi = ServerApi["mcp"] type PermissionApi = ServerApi["permission"] type QuestionApi = ServerApi["question"] @@ -142,7 +148,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient - serverAPI: CatalogApi & { readonly project: ProjectApi } + serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi } protocol?: Promise scope: ServerScope requestFailedTitle: string @@ -157,7 +163,10 @@ export async function bootstrapGlobal(input: { input.queryClient.fetchQuery( loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), ), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), + () => + input.queryClient.fetchQuery( + loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol), + ), () => input.queryClient .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) @@ -298,15 +307,17 @@ export const loadCommands = ( export const loadPathQuery = ( scope: ServerScope, directory: string | null, + api: LocationApi, sdk: OpencodeClient, protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "path"], queryFn: async () => { - if ((await protocol) !== "v1") - return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } - return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + if ((await protocol) === "v1") { + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + } + return retry(() => api.get({ location: directory ? { directory } : undefined })).then(locationPath) }, }) @@ -336,6 +347,7 @@ export async function bootstrapDirectory(input: { readonly agent: AgentListApi readonly command: CommandListApi readonly mcp: McpApi + readonly location: LocationApi readonly permission: PermissionApi readonly project: ProjectApi readonly question: QuestionApi @@ -418,7 +430,7 @@ export async function bootstrapDirectory(input: { !seededPath && (() => input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol)) .then((data) => { const next = projectID(data.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 13a0b74bc6f3..21c0f52bf133 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -189,7 +189,7 @@ function makeQueryOptionsApi( providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), + loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), diff --git a/packages/app/src/utils/location-path.ts b/packages/app/src/utils/location-path.ts new file mode 100644 index 000000000000..ecfd1b98b4df --- /dev/null +++ b/packages/app/src/utils/location-path.ts @@ -0,0 +1,14 @@ +import { Location } from "@opencode-ai/schema/location" +import type { Path } from "@opencode-ai/sdk/v2/client" +import { Schema } from "effect" + +export function locationPath(input: unknown): Path { + const location = Schema.decodeUnknownSync(Location.Details)(input) + return { + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: location.home, + } +} diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c8742a..356b88254f11 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -113,6 +113,7 @@ export type LocationGetOutput = { readonly directory: string readonly workspaceID?: string readonly project: { readonly id: string; readonly directory: string } + readonly home: string } export type AgentsListInput = { diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 8228b8599ea3..c09960eb1bb4 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,12 +1,12 @@ import { Context, Effect, Layer } from "effect" -import { Info, Ref, response } from "@opencode-ai/schema/location" +import { Details, Info, Ref, response } from "@opencode-ai/schema/location" import { Project } from "./project" import { LayerNode } from "./effect/layer-node" import { makeLocationNode, tags } from "./effect/app-node" export * as Location from "./location" -export { Info, Ref, response } +export { Details, Info, Ref, response } export interface Interface extends Info { readonly vcs?: Project.Vcs diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index 1752bae9bebe..34eb894682e1 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -29,7 +29,7 @@ export const locationQueryOpenApi = OpenApi.annotations({ export const LocationGroup = HttpApiGroup.make("server.location").add( HttpApiEndpoint.get("location.get", "/api/location", { query: LocationQuery, - success: Location.Info, + success: Location.Details, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index c01ce36372a6..a0e57119fdc3 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -20,6 +20,11 @@ export class Info extends Schema.Class("Location.Info")({ }), }) {} +export class Details extends Schema.Class
("Location.Details")({ + ...Info.fields, + home: AbsolutePath, +}) {} + export function response(data: S) { return Schema.Struct({ location: Info, data }) } diff --git a/packages/schema/test/location.test.ts b/packages/schema/test/location.test.ts new file mode 100644 index 000000000000..97561c57d9ab --- /dev/null +++ b/packages/schema/test/location.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { Location } from "../src/location.js" + +const details = { + directory: "/project", + project: { id: "project", directory: "/project" }, + home: "/home/user", +} + +test("Location.Details includes the server home directory", () => { + expect(String(Schema.decodeUnknownSync(Location.Details)(details as unknown).home)).toBe("/home/user") + expect(() => Schema.decodeUnknownSync(Location.Details)({ ...details, home: undefined } as unknown)).toThrow() +}) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index ded8c8c2e0ac..aa7558e8bf41 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" +import os from "node:os" import { Api } from "../api" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => @@ -8,10 +10,11 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han "location.get", Effect.fn(function* () { const location = yield* Location.Service - return new Location.Info({ + return new Location.Details({ directory: location.directory, workspaceID: location.workspaceID, project: location.project, + home: AbsolutePath.make(os.homedir()), }) }), ), From b92a164b06992aca865a137681e4b79267b6b491 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 08:08:02 +0800 Subject: [PATCH 2/7] fix(app): list default project directory --- .../directory-picker-domain.test.ts | 24 +++++++++++++++++++ .../src/components/directory-picker-domain.ts | 5 ++++ 2 files changed, 29 insertions(+) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1bc9af08334c..6d447cae8361 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -152,6 +152,30 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) +test("lists the default directory when the query is empty", async () => { + const calls: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + calls.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "projects/", type: "directory" }, + { path: "README.md", type: "file" }, + ], + }) + }, + find: () => Promise.reject(new Error("empty queries should not use file search")), + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + expect(await search("")).toEqual(["/home/luke", "/home/luke/projects"]) + expect(calls).toEqual(["/home/luke"]) +}) + test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01dc..513ea42e1d56 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -371,6 +371,11 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const input = scoped(value) if (!input) return [] as string[] const raw = normalizePickerDrive(value) + if (!raw) { + const matches = await match(input.directory, "", 50) + if (!active()) return [] + return [input.directory, ...matches] + } const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { From 79bbd776814266324d3f6e42e95e08090b3721d1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 08:14:36 +0800 Subject: [PATCH 3/7] fix(opencode): restore empty directory search --- .../directory-picker-domain.test.ts | 24 ------------------- .../src/components/directory-picker-domain.ts | 5 ---- .../routes/instance/httpapi/handlers/file.ts | 16 ++++++++++++- .../opencode/test/server/httpapi-file.test.ts | 7 +++++- 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 6d447cae8361..1bc9af08334c 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -152,30 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) -test("lists the default directory when the query is empty", async () => { - const calls: string[] = [] - const sdk = { - api: { - file: { - list: (input: { location?: { directory?: string } }) => { - calls.push(input.location?.directory ?? "") - return Promise.resolve({ - data: [ - { path: "projects/", type: "directory" }, - { path: "README.md", type: "file" }, - ], - }) - }, - find: () => Promise.reject(new Error("empty queries should not use file search")), - }, - }, - } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) - - expect(await search("")).toEqual(["/home/luke", "/home/luke/projects"]) - expect(calls).toEqual(["/home/luke"]) -}) - test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 513ea42e1d56..9539ae1d01dc 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -371,11 +371,6 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const input = scoped(value) if (!input) return [] as string[] const raw = normalizePickerDrive(value) - if (!raw) { - const matches = await match(input.directory, "", 50) - if (!active()) return [] - return [input.directory, ...matches] - } const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { 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 6a826022a0a0..d7eedd8252ff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -47,7 +47,21 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const limit = ctx.query.limit ?? 10 const type = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined) const started = performance.now() - const found = yield* filesystem(FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, limit, type }))) + const found = yield* filesystem( + FileSystem.Service.use((fs) => { + if (ctx.query.query.trim()) return fs.find({ query: ctx.query.query, limit, type }) + return fs + .list({ path: RelativePath.make("") }) + .pipe( + Effect.map((items) => + items + .filter((item) => item.type === (type === "file" ? "file" : "directory")) + .filter((item) => !path.basename(item.path).startsWith(".")) + .slice(0, limit), + ), + ) + }), + ) yield* Effect.logInfo("find file", { query: ctx.query.query, type, diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index ed882ade4652..90d0a23ef974 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -55,9 +55,11 @@ describe("file HttpApi", () => { test("serves search endpoints", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") + await Bun.write(path.join(tmp.path, "src", "index.ts"), "export {}") - const [text, symbols] = await Promise.all([ + const [text, directories, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), + request(FilePaths.findFile, tmp.path, { query: "", dirs: "true" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) const files = await Effect.runPromise( @@ -74,6 +76,9 @@ describe("file HttpApi", () => { expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) + expect(directories.status).toBe(200) + expect(await directories.json()).toEqual(["src/"]) + expect(files.response.status).toBe(200) expect(files.body).toContain("hello.txt") From 0f0ac43190abab4477eaaf37b8b9c349b96ee510 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 08:21:00 +0800 Subject: [PATCH 4/7] fix(app): fall back from empty directory search --- .../directory-picker-domain.test.ts | 38 +++++++++++++++++++ .../src/components/directory-picker-domain.ts | 7 +++- .../routes/instance/httpapi/handlers/file.ts | 16 +------- .../opencode/test/server/httpapi-file.test.ts | 7 +--- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1bc9af08334c..1d95b5614ad8 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -152,6 +152,44 @@ 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 () => { + const calls: string[] = [] + const sdk = { + api: { + file: { + find: () => Promise.resolve({ data: [] }), + list: (input: { location?: { directory?: string } }) => { + calls.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "projects/", type: "directory" }, + { path: "README.md", type: "file" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + expect(await search("")).toEqual(["/home/luke/projects"]) + expect(calls).toEqual(["/home/luke"]) +}) + test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01dc..f05ad47b3f86 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -379,7 +379,12 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) if (!active()) return [] - return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + if (results.length || query) { + return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + } + const fallback = await match(input.directory, "", 50) + if (!active()) return [] + return fallback } const segments = query.replace(/^\/+/, "").split("/") const head = segments.slice(0, -1).filter((part) => part && part !== ".") 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 d7eedd8252ff..6a826022a0a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -47,21 +47,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const limit = ctx.query.limit ?? 10 const type = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined) const started = performance.now() - const found = yield* filesystem( - FileSystem.Service.use((fs) => { - if (ctx.query.query.trim()) return fs.find({ query: ctx.query.query, limit, type }) - return fs - .list({ path: RelativePath.make("") }) - .pipe( - Effect.map((items) => - items - .filter((item) => item.type === (type === "file" ? "file" : "directory")) - .filter((item) => !path.basename(item.path).startsWith(".")) - .slice(0, limit), - ), - ) - }), - ) + const found = yield* filesystem(FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, limit, type }))) yield* Effect.logInfo("find file", { query: ctx.query.query, type, diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 90d0a23ef974..ed882ade4652 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -55,11 +55,9 @@ describe("file HttpApi", () => { test("serves search endpoints", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - await Bun.write(path.join(tmp.path, "src", "index.ts"), "export {}") - const [text, directories, symbols] = await Promise.all([ + const [text, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findFile, tmp.path, { query: "", dirs: "true" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) const files = await Effect.runPromise( @@ -76,9 +74,6 @@ describe("file HttpApi", () => { expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(directories.status).toBe(200) - expect(await directories.json()).toEqual(["src/"]) - expect(files.response.status).toBe(200) expect(files.body).toContain("hello.txt") From 982f63f29d12e55101a2dc87c2f014fc56c4b314 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 09:11:33 +0800 Subject: [PATCH 5/7] Revert "fix(app): default project picker to home" This reverts commit 0f6c270b4c4e96ff7f978c224d704f46398b19ed. --- .../components/dialog-select-directory-v2.tsx | 12 +++------- .../components/dialog-select-directory.tsx | 12 +++------- .../src/context/global-sync/bootstrap.test.ts | 15 +++--------- .../app/src/context/global-sync/bootstrap.ts | 24 +++++-------------- packages/app/src/context/server-sync.tsx | 2 +- packages/app/src/utils/location-path.ts | 14 ----------- packages/client/src/generated/types.ts | 1 - packages/core/src/location.ts | 4 ++-- packages/protocol/src/groups/location.ts | 2 +- packages/schema/src/location.ts | 5 ---- packages/schema/test/location.test.ts | 14 ----------- packages/server/src/handlers/location.ts | 5 +--- 12 files changed, 20 insertions(+), 90 deletions(-) delete mode 100644 packages/app/src/utils/location-path.ts delete mode 100644 packages/schema/test/location.test.ts diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index c08d85d3ddbd..f3376ad35f26 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -9,7 +9,6 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import type { Path } from "@opencode-ai/sdk/v2/client" -import { locationPath } from "@/utils/location-path" import { absoluteTreePath, activeTreeNavigation, @@ -71,15 +70,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) === "v1") { - return sdk.client.path - .get() - .then((result) => result.data) - .catch(() => undefined) - } - return sdk.api.location + if ((await sdk.protocol) !== "v1") return + return sdk.client.path .get() - .then(locationPath) + .then((result) => result.data) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index d22310a778b2..fc248d821e29 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -10,7 +10,6 @@ import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import type { Path } from "@opencode-ai/sdk/v2/client" -import { locationPath } from "@/utils/location-path" interface DialogSelectDirectoryProps { title?: string @@ -64,15 +63,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingHome() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) === "v1") { - return sdk.client.path - .get() - .then((result) => result.data) - .catch(() => undefined) - } - return sdk.api.location + if ((await sdk.protocol) !== "v1") return + return sdk.client.path .get() - .then(locationPath) + .then((result) => result.data) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 428160601a0e..a95706a3dbe5 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -227,21 +227,12 @@ describe("config queries", () => { describe("query keys", () => { test("partitions identical directories by server scope", () => { - const location = {} as Parameters[2] - const client = {} as Parameters[3] + const client = {} as Parameters[2] const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local - expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([ - "local", - "/repo", - "path", - ]) - expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([ - "https://debian.example", - "/repo", - "path", - ]) + expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) + expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 963db6cfcf89..0f3e47381649 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -45,7 +45,6 @@ import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { normalizeSessionInfo } from "@/utils/session" import type { ServerProtocol } from "@/utils/server-protocol" import type { ServerApi } from "@/utils/server" -import { locationPath } from "@/utils/location-path" type GlobalStore = { ready: boolean @@ -120,11 +119,6 @@ type ProjectApi = { readonly current: (input?: ProjectCurrentInput) => Promise } -type LocationApi = { - readonly get: (input?: { - readonly location?: { readonly directory?: string; readonly workspace?: string } - }) => Promise -} type McpApi = ServerApi["mcp"] type PermissionApi = ServerApi["permission"] type QuestionApi = ServerApi["question"] @@ -148,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient - serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi } + serverAPI: CatalogApi & { readonly project: ProjectApi } protocol?: Promise scope: ServerScope requestFailedTitle: string @@ -163,10 +157,7 @@ export async function bootstrapGlobal(input: { input.queryClient.fetchQuery( loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), ), - () => - input.queryClient.fetchQuery( - loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol), - ), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) @@ -307,17 +298,15 @@ export const loadCommands = ( export const loadPathQuery = ( scope: ServerScope, directory: string | null, - api: LocationApi, sdk: OpencodeClient, protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "path"], queryFn: async () => { - if ((await protocol) === "v1") { - return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) - } - return retry(() => api.get({ location: directory ? { directory } : undefined })).then(locationPath) + if ((await protocol) !== "v1") + return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) }, }) @@ -347,7 +336,6 @@ export async function bootstrapDirectory(input: { readonly agent: AgentListApi readonly command: CommandListApi readonly mcp: McpApi - readonly location: LocationApi readonly permission: PermissionApi readonly project: ProjectApi readonly question: QuestionApi @@ -430,7 +418,7 @@ export async function bootstrapDirectory(input: { !seededPath && (() => input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol)) + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) .then((data) => { const next = projectID(data.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 21c0f52bf133..13a0b74bc6f3 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -189,7 +189,7 @@ function makeQueryOptionsApi( providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), path: (directory: PathKey | null) => - loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol), + loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), diff --git a/packages/app/src/utils/location-path.ts b/packages/app/src/utils/location-path.ts deleted file mode 100644 index ecfd1b98b4df..000000000000 --- a/packages/app/src/utils/location-path.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Location } from "@opencode-ai/schema/location" -import type { Path } from "@opencode-ai/sdk/v2/client" -import { Schema } from "effect" - -export function locationPath(input: unknown): Path { - const location = Schema.decodeUnknownSync(Location.Details)(input) - return { - state: "", - config: "", - worktree: location.project.directory, - directory: location.directory, - home: location.home, - } -} diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 356b88254f11..3b3188c8742a 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -113,7 +113,6 @@ export type LocationGetOutput = { readonly directory: string readonly workspaceID?: string readonly project: { readonly id: string; readonly directory: string } - readonly home: string } export type AgentsListInput = { diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index c09960eb1bb4..8228b8599ea3 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,12 +1,12 @@ import { Context, Effect, Layer } from "effect" -import { Details, Info, Ref, response } from "@opencode-ai/schema/location" +import { Info, Ref, response } from "@opencode-ai/schema/location" import { Project } from "./project" import { LayerNode } from "./effect/layer-node" import { makeLocationNode, tags } from "./effect/app-node" export * as Location from "./location" -export { Details, Info, Ref, response } +export { Info, Ref, response } export interface Interface extends Info { readonly vcs?: Project.Vcs diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index 34eb894682e1..1752bae9bebe 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -29,7 +29,7 @@ export const locationQueryOpenApi = OpenApi.annotations({ export const LocationGroup = HttpApiGroup.make("server.location").add( HttpApiEndpoint.get("location.get", "/api/location", { query: LocationQuery, - success: Location.Details, + success: Location.Info, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index a0e57119fdc3..c01ce36372a6 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -20,11 +20,6 @@ export class Info extends Schema.Class("Location.Info")({ }), }) {} -export class Details extends Schema.Class
("Location.Details")({ - ...Info.fields, - home: AbsolutePath, -}) {} - export function response(data: S) { return Schema.Struct({ location: Info, data }) } diff --git a/packages/schema/test/location.test.ts b/packages/schema/test/location.test.ts deleted file mode 100644 index 97561c57d9ab..000000000000 --- a/packages/schema/test/location.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "bun:test" -import { Schema } from "effect" -import { Location } from "../src/location.js" - -const details = { - directory: "/project", - project: { id: "project", directory: "/project" }, - home: "/home/user", -} - -test("Location.Details includes the server home directory", () => { - expect(String(Schema.decodeUnknownSync(Location.Details)(details as unknown).home)).toBe("/home/user") - expect(() => Schema.decodeUnknownSync(Location.Details)({ ...details, home: undefined } as unknown)).toThrow() -}) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index aa7558e8bf41..ded8c8c2e0ac 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,8 +1,6 @@ import { Location } from "@opencode-ai/core/location" -import { AbsolutePath } from "@opencode-ai/core/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import os from "node:os" import { Api } from "../api" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => @@ -10,11 +8,10 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han "location.get", Effect.fn(function* () { const location = yield* Location.Service - return new Location.Details({ + return new Location.Info({ directory: location.directory, workspaceID: location.workspaceID, project: location.project, - home: AbsolutePath.make(os.homedir()), }) }), ), From 6c6e619b78c3571474a24932e519bcdc3062a7eb Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 11:00:20 +0800 Subject: [PATCH 6/7] fix(app): show all fallback project directories --- .../app/src/components/directory-picker-domain.test.ts | 10 ++++++++-- packages/app/src/components/directory-picker-domain.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1d95b5614ad8..c69c3ae8056e 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -168,6 +168,10 @@ test("keeps indexed directory results for servers that support empty search", as test("lists the default directory when empty search is unsupported", async () => { const calls: string[] = [] + const directories = Array.from({ length: 60 }, (_, index) => ({ + path: `project-${index}/`, + type: "directory" as const, + })) const sdk = { api: { file: { @@ -176,7 +180,7 @@ test("lists the default directory when empty search is unsupported", async () => calls.push(input.location?.directory ?? "") return Promise.resolve({ data: [ - { path: "projects/", type: "directory" }, + ...directories, { path: "README.md", type: "file" }, ], }) @@ -186,7 +190,9 @@ test("lists the default directory when empty search is unsupported", async () => } as unknown as Parameters[0]["sdk"] const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) - expect(await search("")).toEqual(["/home/luke/projects"]) + const results = await search("") + expect(results).toHaveLength(60) + expect(results.at(-1)).toBe("/home/luke/project-59") expect(calls).toEqual(["/home/luke"]) }) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index f05ad47b3f86..630642b139f3 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -382,7 +382,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string if (results.length || query) { return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) } - const fallback = await match(input.directory, "", 50) + const fallback = (await directories(input.directory)).map((item) => item.absolute) if (!active()) return [] return fallback } From e38000c4433e548742fdb930b75819d0f2dae962 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 8 Aug 2026 11:14:11 +0800 Subject: [PATCH 7/7] fix(app): fall back from typed directory search --- .../directory-picker-domain.test.ts | 21 +++++++++++++++++++ .../src/components/directory-picker-domain.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index c69c3ae8056e..c733a35e6b81 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -139,6 +139,7 @@ test("resolves directory autocomplete from the current browser root", async () = directories.push(input.location?.directory ?? "") return Promise.resolve({ data: [] }) }, + list: () => Promise.resolve({ data: [] }), }, }, } as unknown as Parameters[0]["sdk"] @@ -196,6 +197,26 @@ 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 () => { + const sdk = { + api: { + file: { + find: () => Promise.resolve({ data: [] }), + list: () => + Promise.resolve({ + data: [ + { path: "Documents/", type: "directory" }, + { path: "Downloads/", type: "directory" }, + ], + }), + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + expect(await search("documents")).toEqual(["/home/luke/Documents"]) +}) + test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 630642b139f3..dfd8ee74addc 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -379,10 +379,12 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) if (!active()) return [] - if (results.length || query) { + if (results.length) { return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) } - const fallback = (await directories(input.directory)).map((item) => item.absolute) + const fallback = query + ? await match(input.directory, query, 50) + : (await directories(input.directory)).map((item) => item.absolute) if (!active()) return [] return fallback }