Skip to content

Directory picker caches failed listings until the dialog is reopened #49518

Open

Description

@fobiat

Description

On dev 88c6c7a, createDirectorySearch caches a rejected directory listing as a successful empty array. If the connection or directory permissions recover, typing again in the same dialog cannot discover its children. Reopening the dialog is required.

Related to #41412 and #39434. The home-listing fallback from #41158 is already merged; this is a remaining recovery bug.

Plugins

None

OpenCode version

Current dev, 88c6c7a

Steps to reproduce

  1. Open the directory picker and make its first file.list request fail.
  2. Restore access and search the same directory again.
  3. The cached empty result prevents another request.

A regression test against the real createDirectorySearch fails before evicting rejected listings and passes after. Successful listings remain cached. I can submit the small fix with that test.

Operating System

Arch Linux

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the clear report. I reproduced this, and it still happens on the latest dev (950111f).

    The test below runs the real createDirectorySearch against a mock SDK whose first file.list call rejects and whose later calls succeed. Searching /tmp/ twice in the same search instance returns [] both times, and file.list is called only once. The rejected request is cached as an empty listing, so it is never retried until the dialog is reopened. Successful listings stay cached as intended.

    Removing the entry from the cache when the request fails makes the test pass, along with the existing tests in that folder.

    Related: #39434 (empty listing from a missing path parameter) and #41412 (listing $HOME, fixed in #41158). Both are closed and are different problems.

    Reproduction script (directory-search-cache.test.ts)
    // Reproduction for anomalyco/opencode#49518: createDirectorySearch caches a
    // rejected file.list as a successful empty listing for the life of the dialog.
    //
    // Run from a dev checkout:
    //   cp directory-search-cache.test.ts packages/app/src/workspaces/selection/
    //   cd packages/app && bun test src/workspaces/selection/directory-search-cache.test.ts
    import { expect, test } from "bun:test"
    import { createDirectorySearch, listPickerDirectory } from "./domain"
    
    type Sdk = Parameters<typeof listPickerDirectory>[0]
    
    const tree: Record<string, Array<{ path: string; type: "directory" | "file" }>> = {
      "": [{ path: "tmp/", type: "directory" }],
      "/": [{ path: "tmp/", type: "directory" }],
      "/tmp": [
        { path: "/tmp/project-a/", type: "directory" },
        { path: "/tmp/project-b/", type: "directory" },
      ],
    }
    
    const mock = (failFirst: boolean) => {
      const location = { directory: "/" }
      const calls: string[] = []
      const sdk = {
        api: {
          file: {
            find: () => Promise.resolve({ location, data: [] }),
            list: (input: { path: string }) => {
              calls.push(input.path)
              // The first request fails (connection drop / EACCES); later ones succeed.
              if (failFirst && calls.length === 1) return Promise.reject(new Error("network down"))
              return Promise.resolve({ location, data: tree[input.path.replace(/\/+$/, "")] ?? [] })
            },
          },
        },
      } as unknown as Sdk
      const search = createDirectorySearch({ sdk, home: () => "", base: () => "/", location: () => location })
      return { search, calls }
    }
    
    test("a failed file.list is retried on the next search in the same dialog", async () => {
      const { search, calls } = mock(true)
    
      const first = await search("/tmp/")
      console.log("first search (list rejected):", first, "file.list calls:", calls)
      expect(first).toEqual([])
    
      const second = await search("/tmp/")
      console.log("second search (access restored):", second, "file.list calls:", calls)
      expect(second).toEqual(["/tmp/project-a", "/tmp/project-b"])
    })
    
    test("successful listings stay cached", async () => {
      const { search, calls } = mock(false)
      const first = await search("/tmp/")
      const count = calls.length
      expect(first).toEqual(["/tmp/project-a", "/tmp/project-b"])
      expect(await search("/tmp/")).toEqual(first)
      expect(calls.length).toBe(count)
    })

    Run it: bun directory-search-cache.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions