Skip to content

fix(explorer): stop the file filter claiming it searched everything - #6

Merged
nplez1 merged 1 commit into
nplez1/mainfrom
fix/explore-file-filter-partial-results
Sep 19, 2026
Merged

nplez1 merged 1 commit into
nplez1/mainfrom
fix/explore-file-filter-partial-results

Conversation

@nplez1

@nplez1 nplez1 commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​541 $\color{#cf222e}{\Huge{\mathbf{−}}}$​22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​519
Prod 21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​647 $\color{#cf222e}{\Huge{\mathbf{−}}}$​154 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​493

ELI5

The file filter in the file browser was reading only the first page of a very long list of files, and it never said so — so a file further down that list came back as "no files match this filter". On a local workspace it was worse than that: the filter threw away its own results and returned nothing for any search.

Now the filter asks the computer for the files that match what you typed, wherever they are in the workspace. When there are more matches than it will show, it says how many it found and how many it is showing, and asks you to type a bit more.

What Changed

Before, on a local workspace:

  • Typing anything into the Explore pane's Names filter returned zero rows and "No files match this filter" — every query, regardless of whether the file existed. Reported against a file three directories deep in a large repository, which returned nothing for both drover and drover.eve.
  • Filtering ran over a full workspace listing capped at 20 001 paths, truncated in ripgrep's traversal order, with the truncation flag never rendered. On a large workspace a real file could read as "no match" with no hint that the search was partial.
  • Quick Open ranked that capped listing in the renderer.

After:

  • The local filter returns matches from anywhere in the workspace. With more matches than the page holds it shows "Showing the first N of M matches — add more of the name to narrow it down".
  • When the host cannot count matches (SSH, older remote hosts) it shows "Only part of this workspace was searched — add more of the name to narrow it down". "No files match this filter" is now reachable only for a scan that completed.
  • Quick Open takes the same host-side path and renders the flag the host reports, instead of inferring truncation from files.length >= 20 001.

Mechanism

  • Matching moves to the execution host, which is where SSH and remote runtimes already did it; the local window was the only one filtering a truncated page in the renderer.
  • QuickOpenPathRanker gains a sibling, NameFilterPathMatcher, in src/shared/quick-open-path-search.ts. The Explore filter keeps its documented substring-AND semantics (it is deliberately not pointed at the fuzzy ranker), counts every match, and retains the lexicographically first page so the tree stays a stable sorted prefix rather than an arbitrary subset.
  • searchQuickOpenFilePaths takes a mode (quick-open | name-filter) over one ripgrep pass; the shared tokenizer/predicate is now used by both the host matcher and the renderer projection, so the two cannot drift.
  • New fs:searchFilePaths IPC returns { files, totalCount, truncated } and shares the fs:listFiles cancellation registry, so a superseded keystroke kills the previous scan. searchRuntimeFilePaths uses it for a local workspace; an empty query keeps the unscoped listing, so Quick Open still lists files before you type.
  • Remote runtimes and legacy inventories now pass through the totalCount their replies already carried; their wire format is unchanged.

QUICK_OPEN_LISTING_MAX_RESULTS is untouched.

Why

The reported symptom had two independent causes, and the tempting fix — remove the 20 001 limit — is the wrong one:

  1. The filter's projection was gated on a query-scoped listing (resolvedQuery === query). A local listing is unscoped (resolvedQuery === undefined), so every local query resolved to []. This alone explains zero results for any query.
  2. Filtering then depended on a truncated listing. The cap is an OOM bound from fix(memory): bound OOM-prone accumulators across Orca stablyai/orca#10179: QUICK_OPEN_READDIR_MAX_FILES aliases it and the readdir fallback throws past it, the relay clamps to it via resolveQuickOpenResultLimit, and the runtime command path imports it. Raising it would also re-introduce the cost it exists to prevent — the renderer fuzzy-ranks the whole retained array per keystroke.

So the bound stays, and a filtered query stops using the bounded listing at all: it gets its own query-scoped search with a smaller page. That is the design the remote paths already used, now shared with local.

Alternatives considered: raising the cap (rejected above); client-side paging with cursors (rejected — paging over a mutating filesystem needs snapshot tokens and host-side caches, which is the memory the bound exists to bound, and typing one more character is better UX than a "load more" button); streaming results over a new RPC opcode (deferred — docs/reference/remote-wire-compatibility.md Rule 2 says a new stream opcode must be capability-negotiated, which is the follow-up stage).

Design, contract, staging, and the test strategy are written up in docs/reference/file-path-filtering-at-scale.md (linked from AGENTS.md, and allow-listed in .gitignore the way that file's own convention requires for a tracked reference doc).

Linked Issue

No issue — reported directly, fixed on fix/explore-file-filter-partial-results, which is cut from origin/main so its delta is this one commit.

Visual Proof

Pending — no before/after captured. This change is visible in the Explore pane (a partial-result notice replaces a false "No files match"), but this worktree has no rendered-app harness, so it is verified at the unit and integration level instead: see Testing. Both states are easy to capture for anyone with a workspace over ~20 000 files.

Testing

  • pnpm tc — clean (node/cli/web projects).

  • 9 780 tests pass across src/renderer/src/components/right-sidebar, src/renderer/src/runtime, src/renderer/src/web, src/main/ipc, src/relay (1 073 files, 18 skipped), run on the origin/main base.

  • src/main/ipc/filesystem-search-file-paths-real.test.ts is new and drives real ripgrep over a temp workspace: 20 002 filler files plus src/a/b/drover.eve_schema, asserting the query-scoped name-filter search returns that file with totalCount: 1 — the reported scenario. Plus a broad-query case (bounded sorted page + exact count + truncated) and a fuzzy-mode regression case. The suite skips itself when no rg binary is present.

  • The gate that caused the zero-rows bug has a regression test that fails on the previous code: use-file-explorer-name-filter.test.ts → "filters a local full listing that no query produced".

  • check:code-quality:changed, check:max-lines-ratchet, verify:localization-catalog, verify:localization-extraction, check:readme-local-links — all pass.

  • Platforms: macOS only. The SSH/relay and runtime paths are deliberately unchanged, and the added code is path-agnostic (no new shell, shortcut, or path handling). Windows/Linux and a real SSH host were not run locally — CI covers the unit matrix.

  • Not run locally: pnpm lint in full, pnpm build.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Implemented with an agent (DeepSeek v4.1 Flash in the pi harness), with a design consultation from Kimi K3 (Fireworks) and a review pass from a stronger advisor model. The maintainer reported the bug and chose the approach among the presented options.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: the new IPC channel authorizes through the same resolveAuthorizedPath as fs:listFiles, honours the same excludePaths (nested worktrees) and the same hidden-dir/node_modules blocklist, and reuses the sender-scoped cancellation registry. No new file access is granted.
  • Cross-platform: verified only on macOS. wslAwareSpawn and the existing path normalization are untouched; the new matcher compares lowercased /-separated relative paths, as the renderer already did.
  • Remote SSH / mobile: the SSH path is unchanged (it still returns truncated without a count, which the pane now reports honestly). Paired-web gained a files.searchPaths implementation for the new API method. An exact name-filter total over RPC, with version negotiation and a legacy fallback, is stage 2 in the design doc.
  • Backwards compatibility: fs:searchFilePaths is a new channel (same-version local IPC). Remote wire format unchanged; totalCount was already in the runtime reply.
  • Performance: a local keystroke now triggers a debounced (120 ms) ripgrep scan that is aborted on the next keystroke, instead of filtering one capped listing client-side. Host memory is bounded by the page size, not the workspace size — 5 000 paths for the filter, 32 for Quick Open — and the renderer no longer holds or fuzzy-ranks a 20 000-path array per keystroke.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

The Explore pane's filename filter and Quick Open searched local files by scanning
the whole workspace and stopping at 20 001 paths in ripgrep's traversal order.
Whether a file was inside that prefix was arbitrary, and the Explore pane never
showed the truncation flag, so a filter could answer a file that exists with "No
files match this filter" — the reported bug, on a repository large enough that a
deep file fell outside the prefix. A local filter was also broken outright: its
projection was gated on a query-scoped listing, and a local listing is unscoped,
so every local query resolved to zero rows.

Both panes now ask the execution host for the files that match the query, which is
where SSH and remote runtimes already did it:

- `searchQuickOpenFilePaths` takes a matcher mode: the existing fuzzy ranker for
  Quick Open, and a new `NameFilterPathMatcher` for the Explore filter. The matcher
  keeps the pane's substring-AND semantics, counts every match, and retains the
  lexicographically first page, so the tree stays a stable sorted prefix.
- `fs:searchFilePaths` exposes that to the window as
  `{ files, totalCount, truncated }`, sharing the `fs:listFiles` cancellation
  registry so a superseded keystroke kills the previous scan.
- A local workspace takes that path once the query is non-empty; an empty query
  keeps the unscoped listing, so Quick Open still shows files before you type.
  SSH and remote runtimes are unchanged, and now also carry the `totalCount` their
  replies already contained.
- The Explore pane reports a bounded page honestly: "Showing the first N of M
  matches" when the host counted matches, "Only part of this workspace was
  searched" when it could not. "No files match this filter" is now reachable only
  for a scan that completed.

`QUICK_OPEN_LISTING_MAX_RESULTS` is untouched. It is an OOM bound from stablyai#10179,
aliased by the readdir fallback and clamped by the relay, so raising it would
re-open that hole instead of fixing the filter.

Design, contract, and staging live in docs/reference/file-path-filtering-at-scale.md,
linked from AGENTS.md.

`pnpm tc` is clean, 9 780 tests pass across right-sidebar/runtime/web/main-ipc/relay
(`filesystem-search-file-paths-real.test.ts` drives real ripgrep over 20 003 files),
and `check:code-quality:changed` reports no findings.
@nplez1
nplez1 merged commit 88ec27e into nplez1/main Sep 19, 2026
27 of 30 checks passed
nplez1 pushed a commit that referenced this pull request Oct 21, 2026
…tablyai#31066)

* fix(cli): worktree create sends the host --project/--host selected

Codex review 6 #6: the CLI picked the right setup but sent only its repo id,
so a repo id set up on two hosts failed as selector_ambiguous; and
--project-host-setup ignored --host when setup ids repeated.

worktree.create gains optional repoHostId (older hosts drop it and keep the
old behaviour); the runtime narrows the repo selector to that host.

* refactor(runtime): the repo selector carries its host through every lookup

Review follow-up (Codex + Opus on stablyai#31066): a separate repoHostId was dropped
by the startup-agent lookup, the CLI's GitLab link check and the create
dedupe key. RuntimeRepoSelector now carries the host with the selector, so
showRepo/resolveRepoSelector and startup-agent resolution keep it; the
create dedupe key includes it; repo.show gains the same optional
repoHostId; the CLI spreads one CliRepoTarget into every repo lookup.
Handler-level tests cover worktree.create (plain, startupAgent,
startupDraft), agent.launch, repo.show and the dedupe key.

* test(runtime): drop a needless cast in the agent.launch host test; fold an import
nplez1 pushed a commit that referenced this pull request Oct 21, 2026
…p ones (stablyai#31096)

* feat(ci): ratchet raw repo connectionId host reads; one drop-in resolver

Codex review 6 class fix (#4/#6/#7): host decisions read off a repo row's
legacy connectionId miss rows that name SSH only as executionHostId.

- getRepoRoutingConnectionId (shared/execution-host.ts): drop-in for a raw
  repo.connectionId routing check that also finds executionHostId-only SSH.
- check-repo-host-read-ratchet: per-file count of raw repo connectionId
  reads in src/main, src/shared, src/cli; counts may only shrink.
- Migrated 66 cheap reads: runtime GitHub/GitLab commands, PR/MR head ref
  fetches, preserved-branch cleanup, ref queries, base-ref IPC, worktree
  preparation, trash sweep, scan cache TTL, usage metadata.

* fix(ipc): PR base IPC routes executionHostId-only SSH rows; ratchet is a stated lower bound

Review follow-up (Opus on stablyai#31096):
- worktrees:resolvePrBase decided git/gh routing on the raw connectionId while
  its head-ref fetches already used the resolver; one resolved connectionId
  now drives all of it (test fails on main).
- getRepoRoutingConnectionId's contract is stated exactly: never local when the
  raw field is set; on a contradictory ssh row executionHostId wins.
- The ratchet regex no longer matches report.connectionId; header and docs
  say it is a lower bound.
- Regression tests for a runtime GitHub and GitLab command site.

* test(ci): pin the repo host-read ratchet step to the static phase

* fix(ci): repo host-read ratchet skips verbatim field copies into projected rows

A copy of connectionId into a projected row decides nothing; migrating it to
the resolver would change what the projection publishes. The sidebar feed
catalog's copy (added after the baseline) and two older projections no longer
count; baseline pruned to 91.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant