From b42555cf01849e80da0cf3d1204b25140a83f285 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:45:17 +1000 Subject: [PATCH] fix: reuse current location for directory browsing (#46970) Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com> Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> --- .../user-story/model-selection-flow.spec.ts | 20 +- .../app/src/new-session/project/controller.ts | 1 + .../app/src/workspaces/selection/dialog.tsx | 57 +++--- .../src/workspaces/selection/domain.test.ts | 183 ++++++++++++++++-- .../app/src/workspaces/selection/domain.ts | 62 ++++-- .../src/workspaces/selection/picker.test.ts | 1 + .../app/src/workspaces/selection/picker.tsx | 2 + packages/client/src/effect/api/api.ts | 2 +- packages/core/src/filesystem.ts | 13 +- .../core/test/location-filesystem.test.ts | 63 +++++- packages/protocol/src/groups/fs.ts | 9 +- packages/server/test/fixture/mcp-starts.cjs | 20 ++ packages/server/test/fs.test.ts | 79 ++++++++ .../tui/src/component/prompt/autocomplete.tsx | 80 +++----- .../tui/src/prompt/directory-completion.ts | 23 +++ packages/tui/test/prompt/autocomplete.test.ts | 59 ++++++ 16 files changed, 539 insertions(+), 135 deletions(-) create mode 100644 packages/server/test/fixture/mcp-starts.cjs create mode 100644 packages/server/test/fs.test.ts diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index b7711294f88..ee3655a25f6 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page } }), sessions: [], pageMessages: () => ({ items: [] }), - fileList: (path) => - path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], - findFiles: () => ["NewProject"], + // Listings are requested by absolute path and returned relative to the stable Location. + fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []), }) await page.addInitScript(() => { localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) @@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page } const addProject = page.locator('[data-action="home-add-project-row"]') await expectAppVisible(addProject) await addProject.click() - const directoryItem = page.getByRole("treeitem", { name: "NewProject" }) + const picker = page.getByRole("dialog", { name: "Open project", exact: true }) + await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject") + const listing = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode" + }) + await picker.getByRole("button", { name: "Parent", exact: true }).click() + expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory) + const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true }) await expect(directoryItem).toBeVisible() await directoryItem.click() - const selectFolder = page.getByRole("button", { name: "Select folder" }) + await expect(directoryItem).toHaveAttribute("aria-selected", "true") + await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible() + const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true }) await expect(selectFolder).toBeEnabled() await selectFolder.click() + await expect(picker).toBeHidden() await page.locator('[data-action="home-new-session"]').click() await expectAppVisible(page.locator('[data-component="composer"]')) diff --git a/packages/app/src/new-session/project/controller.ts b/packages/app/src/new-session/project/controller.ts index 671ff2da9cd..cf803202167 100644 --- a/packages/app/src/new-session/project/controller.ts +++ b/packages/app/src/new-session/project/controller.ts @@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree if (!connection) return pickDirectory({ server: connection, + location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined, title, onSelect: (result) => { const directory = Array.isArray(result) ? result[0] : result diff --git a/packages/app/src/workspaces/selection/dialog.tsx b/packages/app/src/workspaces/selection/dialog.tsx index fdbcd1a990f..ecefb8ce336 100644 --- a/packages/app/src/workspaces/selection/dialog.tsx +++ b/packages/app/src/workspaces/selection/dialog.tsx @@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { useGlobal } from "@/runtime/server/runtime" import { useLanguage } from "@/runtime/i18n/language" import { ServerConnection } from "@/runtime/server/registry" -import type { Path } from "@/runtime/server/types" +import type { LocationRef } from "@opencode-ai/client/promise" import { absoluteTreePath, activeTreeNavigation, @@ -26,16 +26,19 @@ import { displayPickerPath, pickerParent, pickerRoot, + listPickerDirectory, + pickerRelativePath, + pickerAbsolutePath, } from "./domain" import "./dialog.css" import { Divider } from "@opencode-ai/ui/divider" -import { getFilename } from "@opencode-ai/util/path" interface DirectoryPickerDialogProps { title?: string multiple?: boolean onSelect: (result: string | string[] | null) => void server: ServerConnection.Any + location?: LocationRef mode?: "directory" | "file" start?: string } @@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { let navigation = 0 const [fallbackPath] = createResource( - () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), - () => - sdk.api.location - .get() - .then( - (location): Path => ({ - state: "", - config: "", - worktree: location.project.directory, - directory: location.directory, - home: "", - }), - ) - .catch(() => undefined), + () => (props.location ? undefined : true), + () => sdk.api.location.get().catch(() => undefined), { initialValue: undefined }, ) - const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const home = createMemo(() => sync.data.path.home || "") + const location = createMemo(() => { + const current = props.location ?? fallbackPath() + return current ? { directory: current.directory, workspace: current.workspaceID } : undefined + }) const start = createMemo( () => props.start || sync.data.path.home || + props.location?.directory || sync.data.path.directory || - fallbackPath()?.home || fallbackPath()?.directory, ) - const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) + const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { const cleaned = cleanPickerInput(value) const typed = cleaned.replace(/\/+$/, "") @@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { 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() + const base = location()?.directory if (!base) return { query: value, items: directories.slice(0, 5) } + const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base)) + if (query === undefined) return { query: value, items: directories.slice(0, 5) } const files = await sdk.api.file .find({ - location: { directory: base }, - query: pickerFileSearchQuery(base, value, home()), + location: location(), + query, type: "file", limit: 20, }) @@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { .catch(() => []) const results = [ ...directories, - ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), + ...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })), ] return { query: value, @@ -132,15 +129,9 @@ 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 } }) - .then((result) => - result.data.map((entry) => ({ - name: getFilename(entry.path.replace(/[\\/]+$/, "")), - type: entry.type, - })), - ) - .catch(() => undefined) + const current = location() + if (!current) return Promise.resolve(undefined) + return listPickerDirectory(sdk, current, absolute).catch(() => undefined) }) listings.set(key, request) const nodes = await request @@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { createEffect(() => { const path = start() - if (!path || root()) return + if (!path || !location() || root()) return void navigate(path) }) diff --git a/packages/app/src/workspaces/selection/domain.test.ts b/packages/app/src/workspaces/selection/domain.test.ts index 37b33c44829..e03d70dafa3 100644 --- a/packages/app/src/workspaces/selection/domain.test.ts +++ b/packages/app/src/workspaces/selection/domain.test.ts @@ -16,6 +16,7 @@ import { currentPickerSuggestions, createDirectorySearch, createPriorityTaskQueue, + listPickerDirectory, displayPickerPath, pickerParent, pickerRoot, @@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => { expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op") }) -test("resolves directory autocomplete from the current browser root", async () => { - const directories: string[] = [] +test("resolves directory autocomplete from the browser root without changing location", async () => { + const calls: unknown[] = [] + const location = { directory: "/repo", workspace: "workspace_1" } const sdk = { api: { file: { - find: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ data: [] }) + find: (input: unknown) => { + calls.push(input) + return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] }) }, list: () => Promise.resolve({ data: [] }), }, }, } as unknown as Parameters[0]["sdk"] let base = "/repo" - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location }) - await search("components") + expect(await search("components")).toEqual(["/repo/src/components"]) base = "/repo/src" - await search("components") + expect(await search("components")).toEqual(["/repo/src/components"]) - expect(directories).toEqual(["/repo", "/repo/src"]) + expect(calls).toEqual([ + { location, query: "components", type: "directory", limit: 50 }, + { location, query: "src/components", type: "directory", limit: 50 }, + ]) }) -test("keeps indexed directory results for servers that support empty search", async () => { +test("lists absolute parents and preloads siblings through a stable workspace", async () => { + const calls: unknown[] = [] + const location = { directory: "/repo/current", workspace: "workspace_1" } const sdk = { api: { file: { - find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }), + list: async (input: { path?: string }) => { + calls.push(input) + return { + location, + data: + input.path === "/repo" + ? [ + { path: "./", type: "directory" }, + { path: "../sibling/", type: "directory" }, + ] + : [{ path: "../sibling/src/", type: "directory" }], + } + }, + }, + }, + } as unknown as Parameters[0] + expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([ + { name: "current", absolute: "/repo/current", type: "directory" }, + { name: "sibling", absolute: "/repo/sibling", type: "directory" }, + ]) + expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([ + { name: "src", absolute: "/repo/sibling/src", type: "directory" }, + ]) + expect(calls).toEqual([ + { location, path: "/repo" }, + { location, path: "/repo/sibling" }, + ]) +}) + +test("uses listings for typed searches outside the current location", async () => { + const calls: unknown[] = [] + const location = { directory: "/repo/current", workspace: "workspace_1" } + const sdk = { + api: { + file: { + find: () => Promise.reject(new Error("outside searches must not change location")), + list: async (input: unknown) => { + calls.push(input) + return { location, data: [{ path: "../sibling/", type: "directory" }] } + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location }) + expect(await search("sib")).toEqual(["/repo/sibling"]) + expect(calls).toEqual([{ location, path: "/repo" }]) +}) + +test("keeps literal tilde directory names in server listing and search results", async () => { + const location = { directory: "/repo" } + const sdk = { + api: { + file: { + list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }), + find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }), + }, + }, + } as unknown as Parameters[0] + expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([ + { name: "~", absolute: "/repo/~", type: "directory" }, + ]) + const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location }) + expect(await search("nested")).toEqual(["/repo/~/nested"]) +}) + +test("discards stale typed results without changing the request location", async () => { + const location = { directory: "/repo" } + const pending = Promise.withResolvers<{ + location: typeof location + data: Array<{ path: string; type: "directory" }> + }>() + const calls: unknown[] = [] + const sdk = { + api: { + file: { + find: async (input: { query: string }) => { + calls.push(input) + if (input.query === "old") return pending.promise + return { location, data: [{ path: "new/", type: "directory" }] } + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location }) + const stale = search("old") + expect(await search("new")).toEqual(["/repo/new"]) + pending.resolve({ location, data: [{ path: "old/", type: "directory" }] }) + expect(await stale).toEqual([]) + expect(calls).toEqual([ + { location, query: "old", type: "directory", limit: 50 }, + { location, query: "new", type: "directory", limit: 50 }, + ]) +}) + +test("maps server-native drive and share paths without rebasing the location", async () => { + const calls: unknown[] = [] + const sdk = { + api: { + file: { + list: async (input: { location: { directory: string }; path: string }) => { + calls.push(input) + return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] } + }, + }, + }, + } as unknown as Parameters[0] + const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" } + expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([ + { name: "sibling", type: "directory", absolute: "C:/Repo/sibling" }, + ]) + const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" } + expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([ + { name: "sibling", type: "directory", absolute: "//Server/Share/sibling" }, + ]) + expect(calls).toEqual([ + { location: drive, path: "c:/repo" }, + { location: share, path: "//server/share" }, + ]) +}) + +test("keeps indexed directory results for servers that support empty search", async () => { + const location = { directory: "/home/luke" } + const sdk = { + api: { + file: { + find: () => Promise.resolve({ location, 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" }) + const search = createDirectorySearch({ + sdk, + home: () => "/home/luke", + base: () => "/home/luke", + location: () => location, + }) expect(await search("")).toEqual(["/home/luke/projects"]) }) test("lists the default directory when empty search is unsupported", async () => { + const location = { directory: "/home/luke" } const calls: string[] = [] const directories = Array.from({ length: 60 }, (_, index) => ({ path: `project-${index}/`, @@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () => list: (input: { location?: { directory?: string } }) => { calls.push(input.location?.directory ?? "") return Promise.resolve({ + location, data: [...directories, { path: "README.md", type: "file" }], }) }, }, }, } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + const search = createDirectorySearch({ + sdk, + home: () => "/home/luke", + base: () => "/home/luke", + location: () => location, + }) const results = await search("") expect(results).toHaveLength(60) @@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () => }) test("matches the default directory listing when typed search is unsupported", async () => { + const location = { directory: "/home/luke" } const sdk = { api: { file: { find: () => Promise.resolve({ data: [] }), list: () => Promise.resolve({ + location, data: [ { path: "Documents/", type: "directory" }, { path: "Downloads/", type: "directory" }, @@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a }, }, } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + const search = createDirectorySearch({ + sdk, + home: () => "/home/luke", + base: () => "/home/luke", + location: () => location, + }) expect(await search("documents")).toEqual(["/home/luke/Documents"]) }) test("searches from an absolute root without a default base", async () => { + const location = { directory: "/" } const directories: string[] = [] const sdk = { api: { @@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => { list: (input: { location?: { directory?: string } }) => { directories.push(input.location?.directory ?? "") return Promise.resolve({ + location, data: [ { path: "Users/", type: "directory" }, { path: "tmp/", type: "directory" }, @@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => { }, }, } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location }) expect(await search("/")).toEqual(["/Users", "/tmp"]) expect(directories).toEqual(["/"]) diff --git a/packages/app/src/workspaces/selection/domain.ts b/packages/app/src/workspaces/selection/domain.ts index 9a2c1e6ff47..ae8cc33f5c2 100644 --- a/packages/app/src/workspaces/selection/domain.ts +++ b/packages/app/src/workspaces/selection/domain.ts @@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string) export function pickerAbsoluteInput(input: string, home: string, current: string) { const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home)) + return pickerAbsolutePath(value, current) +} + +export function pickerAbsolutePath(input: string, current: string) { + const value = normalizePickerDrive(input) const absolute = pickerRoot(value) ? value : joinPickerPath(current, value) return canonicalPickerPath(absolute) } @@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) { return pickerTilde(value, home) || value } -export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { +export async function listPickerDirectory( + sdk: ServerSDK, + location: { directory: string; workspace?: string }, + directory: string, +) { + const result = await sdk.api.file.list({ location, path: directory }) + return result.data.map((entry) => { + const absolute = pickerAbsolutePath(entry.path, result.location.directory) + return { name: getFilename(absolute), type: entry.type, absolute } + }) +} + +export function createDirectorySearch(args: { + sdk: ServerSDK + location: () => { directory: string; workspace?: string } | undefined + base: () => string | undefined + home: () => string +}) { const cache = new Map>>() let current = 0 @@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string } const directories = async (directory: string) => { - const key = trimPickerPath(directory) + const location = args.location() + if (!location) return [] + const key = JSON.stringify([location, 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 = listPickerDirectory(args.sdk, location, directory) .catch(() => []) - .then((nodes) => - nodes - .filter((node) => node.type === "directory") - .map((node) => { - const relative = trimPickerPath(normalizePickerDrive(node.path)) - return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } - }), - ) + .then((nodes) => nodes.filter((node) => node.type === "directory")) cache.set(key, request) return request } @@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const active = () => token === current const value = cleanPickerInput(filter) const input = scoped(value) - if (!input) return [] as string[] + const location = args.location() + if (!input || !location) return [] as string[] const raw = normalizePickerDrive(value) 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(() => []) + const relative = pickerRelativePath(location.directory, input.directory) + const results = + relative === undefined + ? [] + : await args.sdk.api.file + .find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 }) + .then((result) => + result.data + .map((entry) => pickerAbsolutePath(entry.path, result.location.directory)) + .filter((path) => treePathWithin(input.directory, path)), + ) + .catch(() => []) if (!active()) return [] if (results.length) { - return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + return results.slice(0, 50) } const fallback = query ? await match(input.directory, query, 50) diff --git a/packages/app/src/workspaces/selection/picker.test.ts b/packages/app/src/workspaces/selection/picker.test.ts index c5c8b29d669..9dc2c679070 100644 --- a/packages/app/src/workspaces/selection/picker.test.ts +++ b/packages/app/src/workspaces/selection/picker.test.ts @@ -16,6 +16,7 @@ describe("directoryPickerKind", () => { test("uses the native picker only for local desktop projects", () => { expect(directoryPickerKind("desktop", local)).toBe("native") expect(directoryPickerKind("desktop", remote)).toBe("server") + expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server") expect(directoryPickerKind("web", local)).toBe("server") }) }) diff --git a/packages/app/src/workspaces/selection/picker.tsx b/packages/app/src/workspaces/selection/picker.tsx index ae1bd769b91..a8098948a1b 100644 --- a/packages/app/src/workspaces/selection/picker.tsx +++ b/packages/app/src/workspaces/selection/picker.tsx @@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ServerConnection } from "@/runtime/server/registry" import { usePlatform } from "@/runtime/platform/platform" import { lazy } from "solid-js" +import type { LocationRef } from "@opencode-ai/client/promise" import { directoryPickerKind } from "./policy" const DirectoryPickerDialog = lazy(() => @@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() => type DirectoryPickerInput = { server: ServerConnection.Any + location?: LocationRef title?: string multiple?: boolean onSelect: (result: string | string[] | null) => void diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 45ce1d7359a..1eeb1409391 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1559,7 +1559,7 @@ export interface PermissionApi { export type FileListInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined - readonly path?: RelativePath | undefined + readonly path?: string | undefined } export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray } export type FileListOperation = (input?: FileListInput) => Effect.Effect diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 50a8b225049..b1464b1fed4 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -25,7 +25,7 @@ export const Content = Schema.Struct({ export type Content = typeof Content.Type export const ListInput = Schema.Struct({ - path: RelativePath.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), }) export type ListInput = typeof ListInput.Type @@ -93,17 +93,18 @@ const baseLayer = Layer.effect( } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { - const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) + // Navigation can leave the cwd without activating another Location. + const directory = path.resolve(location.directory, input.path ?? ".") + const info = yield* fs.stat(directory).pipe(Effect.orDie) if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) - return yield* fs.readDirectoryEntries(target.real).pipe( + return yield* fs.readDirectoryEntries(directory).pipe( Effect.orDie, Effect.map((items) => items .flatMap((item) => { if (item.type !== "file" && item.type !== "directory") return [] - const absolute = path.join(target.absolute, item.name) - const relative = path.relative(target.directory, absolute) + const absolute = path.join(directory, item.name) + const relative = path.relative(location.directory, absolute) || "." return [ Entry.make({ path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index 0be4a284a53..5e8e291b82a 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -83,6 +83,34 @@ describe("FileSystem", () => { ), ) + it.live("lists parents and siblings with paths relative to the current location", () => + withTmp((directory) => + Effect.gen(function* () { + const current = path.join(directory, "current") + yield* Effect.promise(() => fs.mkdir(current)) + yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling"))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside")) + yield* Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const parent = yield* filesystem.list({ path: RelativePath.make("..") }) + expect(parent).toHaveLength(2) + expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual( + expect.arrayContaining([ + { path: "." + path.sep, type: "directory" }, + { path: path.join("..", "sibling") + path.sep, type: "directory" }, + ]), + ) + const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") }) + expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([ + { path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" }, + ]) + const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") }) + expect(absolute).toEqual(sibling) + }).pipe(provide(current)) + }), + ), + ) + it.live("canonicalizes local symlinked directories", () => withTmp((directory) => Effect.gen(function* () { @@ -105,10 +133,37 @@ describe("FileSystem", () => { it.live("rejects lexical escapes", () => withTmp((directory) => Effect.gen(function* () { - const filesystem = yield* FileSystem.Service - const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) - }).pipe(provide(directory)), + const current = path.join(directory, "current") + yield* Effect.promise(() => fs.mkdir(current)) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside")) + yield* Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + }).pipe(provide(current)) + }), + ), + ) + + it.live("allows listing through an external symlink without allowing file reads", () => + withTmp((directory) => + Effect.gen(function* () { + const current = path.join(directory, "current") + const outside = path.join(directory, "outside") + yield* Effect.promise(() => fs.mkdir(current)) + yield* Effect.promise(() => fs.mkdir(outside)) + yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside")) + yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction")) + yield* Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const entries = yield* filesystem.list({ path: RelativePath.make("link") }) + expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([ + { path: RelativePath.make(path.join("link", "file.txt")), type: "file" }, + ]) + const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + }).pipe(provide(current)) + }), ), ) }) diff --git a/packages/protocol/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts index 79dd045d322..47812af8ed0 100644 --- a/packages/protocol/src/groups/fs.ts +++ b/packages/protocol/src/groups/fs.ts @@ -1,13 +1,15 @@ import { FileSystem } from "@opencode-ai/schema/filesystem" import { Location } from "@opencode-ai/schema/location" -import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema" +import { PositiveInt } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" const ListQuery = Schema.Struct({ ...LocationQuery.fields, - path: RelativePath.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional).annotate({ + description: "An absolute path or a path relative to the requested location. Defaults to the location directory.", + }), }) const FindQuery = Schema.Struct({ @@ -42,7 +44,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs") OpenApi.annotations({ identifier: "v2.fs.list", summary: "List directory", - description: "List direct children of one directory relative to the requested location.", + description: + "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.", }), ), ) diff --git a/packages/server/test/fixture/mcp-starts.cjs b/packages/server/test/fixture/mcp-starts.cjs new file mode 100644 index 00000000000..4888205b398 --- /dev/null +++ b/packages/server/test/fixture/mcp-starts.cjs @@ -0,0 +1,20 @@ +const fs = require("node:fs") +const readline = require("node:readline") + +fs.appendFileSync(process.argv[2], `${process.pid}\n`) +readline + .createInterface({ input: process.stdin }) + .on("line", (line) => { + const request = JSON.parse(line) + if (request.id === undefined) return + const result = + request.method === "initialize" + ? { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "filesystem-test", version: "1" }, + } + : { tools: [] } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n") + }) + .on("close", () => process.exit(0)) diff --git a/packages/server/test/fs.test.ts b/packages/server/test/fs.test.ts new file mode 100644 index 00000000000..ed8070ef7d1 --- /dev/null +++ b/packages/server/test/fs.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Effect, Schedule } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { startServer } from "./fixture/server" + +it.live( + "browsing parents and siblings reuses the current Location and its MCP process", + () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const current = path.join(tmp.path, "root", "current") + const starts = path.join(tmp.path, "starts") + yield* Effect.promise(async () => { + await fs.mkdir(current, { recursive: true }) + await fs.mkdir(path.join(tmp.path, "root", ".git")) + await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true }) + await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside") + await fs.writeFile(starts, "") + await fs.writeFile( + path.join(tmp.path, "root", "opencode.json"), + JSON.stringify({ + mcp: { + servers: { + filesystem: { + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts], + }, + }, + }, + }), + ) + }) + const server = yield* startServer(path.join(tmp.path, "config")) + const list = (directory: string) => + Effect.promise(async () => { + const url = new URL("/api/fs/list", server.base) + url.searchParams.set("location[directory]", current) + url.searchParams.set("path", directory) + const response = await fetch(url, { headers: server.headers }) + expect(response.status).toBe(200) + const result = await response.json() + expect(result.location.directory).toBe(current) + return result.data + }) + const loaded = Effect.promise(async () => { + const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers }) + expect(response.status).toBe(200) + return response.json() + }) + const count = Effect.promise( + async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length, + ) + + yield* list(".") + expect(yield* loaded).toEqual([{ directory: current }]) + expect( + yield* count.pipe( + Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }), + Effect.timeout("5 seconds"), + ), + ).toBe(1) + + yield* list("..") + const sibling = yield* list("../sibling") + expect(sibling).toEqual([ + { path: path.join("..", "sibling", "nested") + path.sep, type: "directory" }, + { path: path.join("..", "sibling", "file.txt"), type: "file" }, + ]) + expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling) + yield* list("../sibling/nested") + yield* list("../sibling") + expect(yield* loaded).toEqual([{ directory: current }]) + expect(yield* count).toBe(1) + }), + 15_000, +) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index abd4e47f6f3..862e0db1934 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -25,13 +25,7 @@ import { Skill } from "@opencode-ai/schema/skill" import { stringWidth } from "../../util/string-width" import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse" import { moveSelection, reconcileSelectionWindow, revealSelectionOffset } from "../../ui/select-controller" -import { - directoryAutocompleteExactValue, - directoryAutocompleteMatches, - directoryAutocompleteResultValue, - directoryAutocompleteSearch, - slashArgumentAutocomplete, -} from "../../prompt/directory-completion" +import { directoryAutocomplete, slashArgumentAutocomplete } from "../../prompt/directory-completion" export type AutocompleteRef = { onInput: (value: string) => void @@ -343,20 +337,38 @@ export function Autocomplete(props: { if (referenceMatch()) return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true } const { lineRange, base } = parseFileLineRange(input.query ?? "") - const directorySearch = - input.visible === "directory" - ? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home) - : undefined - const requestLocation = { - directory: directorySearch?.directory ?? input.location?.directory, + directory: input.location?.directory, workspace: input.location?.workspaceID ?? data.location.default().workspaceID, } - const result = await ( - input.visible === "directory" - ? client.api.file.list({ location: requestLocation }) - : client.api.file.find({ query: base, limit: 20, location: requestLocation }) - ).then( + const width = props.anchor().width - 4 + if (input.visible === "directory") { + const result = await directoryAutocomplete( + client.api.file, + { ...requestLocation, directory: requestLocation.directory ?? paths.cwd }, + base, + paths.home, + ).catch(() => undefined) + if (!result) + return info.value?.mode === input.visible + ? { ...info.value, failed: true } + : { options: [], failed: true, mode: input.visible, query: input.query, resolved: false } + return { + options: result.map((item) => ({ + display: Locale.truncateMiddle(item.value, width), + value: item.value, + isDirectory: true, + path: item.value, + absolute: item.absolute, + onSelect: () => insertDirectory(item.value), + })), + failed: false, + mode: input.visible, + query: input.query, + resolved: true, + } + } + const result = await client.api.file.find({ query: base, limit: 20, location: requestLocation }).then( (result) => result, () => undefined, ) @@ -368,38 +380,8 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] - const width = props.anchor().width - 4 - const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined - if (exact) { - options.push({ - display: Locale.truncateMiddle(exact, width), - value: exact, - isDirectory: true, - path: exact, - absolute: result.location.directory, - onSelect: () => insertDirectory(exact), - }) - } - const entries = - input.visible === "directory" - ? result.data.filter( - (item) => - item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""), - ) - : result.data options.push( - ...entries.map((item): AutocompleteOption => { - if (input.visible === "directory") { - const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path - return { - display: Locale.truncateMiddle(directory, width), - value: directory, - isDirectory: true, - path: directory, - absolute: path.resolve(result.location.directory, item.path), - onSelect: () => insertDirectory(directory), - } - } + ...result.data.map((item): AutocompleteOption => { const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) return { display: Locale.truncateMiddle(filename, width), diff --git a/packages/tui/src/prompt/directory-completion.ts b/packages/tui/src/prompt/directory-completion.ts index cacbb2d7bf5..a7c8500bca8 100644 --- a/packages/tui/src/prompt/directory-completion.ts +++ b/packages/tui/src/prompt/directory-completion.ts @@ -1,8 +1,31 @@ import type { KeymapCommand } from "@opencode-ai/plugin/tui/context" +import type { OpenCodeClient } from "@opencode-ai/client" import path from "path" import { displaySlice, promptOffsetWidth } from "./display" import { parseSlashHead } from "./parse" +export async function directoryAutocomplete( + file: Pick, + location: { directory: string; workspace?: string }, + query: string, + home: string, +) { + const search = directoryAutocompleteSearch(query, location.directory, home) + const result = await file.list({ location, path: search.directory }) + const exact = directoryAutocompleteExactValue(query, search) + return [ + ...(exact ? [{ value: exact, absolute: search.directory }] : []), + ...result.data + .filter((item) => item.type === "directory") + .map((item) => path.resolve(result.location.directory, item.path)) + .filter((absolute) => directoryAutocompleteMatches(path.basename(absolute), search.query)) + .map((absolute) => ({ + value: directoryAutocompleteResultValue(path.basename(absolute) + "/", search), + absolute, + })), + ] +} + export function slashArgumentAutocomplete( value: string, offset: number, diff --git a/packages/tui/test/prompt/autocomplete.test.ts b/packages/tui/test/prompt/autocomplete.test.ts index 9b9dfc6d7c2..b7527f9f6dc 100644 --- a/packages/tui/test/prompt/autocomplete.test.ts +++ b/packages/tui/test/prompt/autocomplete.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import type { KeymapCommand } from "@opencode-ai/plugin/tui/context" import { + directoryAutocomplete, directoryAutocompleteExactValue, directoryAutocompleteMatches, directoryAutocompleteResultValue, @@ -10,6 +11,64 @@ import { slashArgumentAutocomplete, } from "../../src/prompt/directory-completion" +describe("directoryAutocomplete", () => { + test("lists parents and siblings through the current workspace without changing location", async () => { + const location = { directory: "/project/current", workspace: "workspace_1" } + const calls: unknown[] = [] + const file = { + list: async (input: unknown) => { + calls.push(input) + return { + location: { directory: location.directory, workspaceID: location.workspace }, + data: [ + { path: "./", type: "directory" }, + { path: "../sibling/", type: "directory" }, + { path: "../.hidden/", type: "directory" }, + { path: "../README.md", type: "file" }, + ], + } + }, + } as Parameters[0] + expect(await directoryAutocomplete(file, location, "..", "/home/user")).toEqual([ + { value: "..", absolute: path.resolve("/project") }, + { value: "../current/", absolute: path.resolve("/project/current") }, + { value: "../sibling/", absolute: path.resolve("/project/sibling") }, + ]) + expect(await directoryAutocomplete(file, location, "../sib", "/home/user")).toEqual([ + { value: "../sibling/", absolute: path.resolve("/project/sibling") }, + ]) + expect(calls).toEqual([ + { location, path: path.resolve("/project") }, + { location, path: path.resolve("/project") }, + ]) + }) + + test("resolves home and nested sibling completions against the current location", async () => { + const location = { directory: "/project/current", workspace: "workspace_1" } + const calls: unknown[] = [] + const file = { + list: async (input: unknown) => { + calls.push(input) + return { + location: { directory: location.directory }, + data: [{ path: "../sibling/src/", type: "directory" }], + } + }, + } as Parameters[0] + expect(await directoryAutocomplete(file, location, "../sibling/s", "/project/sibling")).toEqual([ + { value: "../sibling/src/", absolute: path.resolve("/project/sibling/src") }, + ]) + expect(await directoryAutocomplete(file, location, "~", "/project/sibling")).toEqual([ + { value: "~", absolute: "/project/sibling" }, + { value: "~/src/", absolute: path.resolve("/project/sibling/src") }, + ]) + expect(calls).toEqual([ + { location, path: path.resolve("/project/sibling") }, + { location, path: "/project/sibling" }, + ]) + }) +}) + const commands = [ { id: "session.cd",