Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/e2e/utils/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
6 changes: 6 additions & 0 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.promise(() => 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(
Expand Down
32 changes: 4 additions & 28 deletions packages/app/src/workspaces/selection/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand All @@ -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,
})),
Expand Down Expand Up @@ -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" }) {
Expand Down
51 changes: 16 additions & 35 deletions packages/app/src/workspaces/selection/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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<typeof createDirectorySearch>[0]["sdk"]
Expand All @@ -153,34 +150,19 @@ 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<typeof createDirectorySearch>[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}/`,
type: "directory" as const,
}))
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" }],
})
},
},
Expand All @@ -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" },
],
Expand All @@ -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" },
],
Expand Down
20 changes: 5 additions & 15 deletions packages/app/src/workspaces/selection/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 !== ".")
Expand Down
13 changes: 11 additions & 2 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -54,6 +54,14 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E>
}

export type BrowseListInput = { readonly directory: AbsolutePath }
export type BrowseListOutput = { readonly directory: AbsolutePath; readonly entries: ReadonlyArray<FileSystem.Entry> }
export type BrowseListOperation<E = never> = (input: BrowseListInput) => Effect.Effect<BrowseListOutput, E>

export interface BrowseApi<E = never> {
readonly list: BrowseListOperation<E>
}

export type LocationGetInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
Expand Down Expand Up @@ -2085,6 +2093,7 @@ export interface ConfigApi<E = never> {
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly server: ServerApi<E>
readonly browse: BrowseApi<E>
readonly location: LocationApi<E>
readonly agent: AgentApi<E>
readonly plugin: PluginApi<E>
Expand Down
10 changes: 10 additions & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ClientApi } from "../../contract"
import type {
HealthGetOutput,
ServerGetOutput,
BrowseListInput,
BrowseListOutput,
LocationGetInput,
LocationGetOutput,
AgentListInput,
Expand Down Expand Up @@ -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<BrowseListOutput>()(
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<LocationGetOutput>()(
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
Expand Down Expand Up @@ -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"]),
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
HealthGetOutput,
ServerGetOutput,
BrowseListInput,
BrowseListOutput,
LocationGetInput,
LocationGetOutput,
AgentListInput,
Expand Down Expand Up @@ -417,6 +419,20 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
browse: {
list: (input: BrowseListInput, requestOptions?: RequestOptions) =>
request<BrowseListOutput>(
{
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<LocationGetOutput>(
Expand Down
14 changes: 12 additions & 2 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {

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 }
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<FileSystemEntry> }

export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2539,6 +2545,10 @@ export type HealthGetOutput = ServiceHealth

export type ServerGetOutput = { urls: Array<string> }

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
Expand Down
Loading
Loading