Skip to content

Unified workspace search on fff, content search, and a tabbed cmd+K palette - #856

Merged
wingleeio merged 39 commits into
zeronsh:mainfrom
jsgrrchg:feat/workspace-search-fff
Oct 10, 2026
Merged

wingleeio merged 39 commits into
zeronsh:mainfrom
jsgrrchg:feat/workspace-search-fff

Conversation

@jsgrrchg

@jsgrrchg jsgrrchg commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Workspace search had two unrelated engines, and neither scaled:

  • @ mentions (SearchFiles) re-walked the whole checkout whenever its 10-second cache expired, then ranked with nucleo-matcher.
  • The file tree search (SearchWorkspaceFiles) walked the disk on every keystroke: ~28 ms in this repo, 210–410 ms in a 28k-file monorepo.
  • Content search did not exist, and cmd+K only searched actions and chats.

This PR puts all three surfaces on one engine-side index built on fff-search (=0.11.0). The index is kept current by a file watcher and bounded in memory. On top of it, the cmd+K palette is redesigned with tabs and sections, and it can now search file names and file contents in the focused chat's workspace.

Surface Before After
Composer @ (SearchFiles) full walk every 10 s + nucleo-matcher fff index, ~1 ms
File tree search (SearchWorkspaceFiles) full walk per query fff index, ~1–4 ms
cmd+K — threads substring filter, sidebar order scored ranking
cmd+K — files — fff names, focused chat's workspace
cmd+K — file contents — new SearchWorkspaceContent RPC
New-project picker browse folder by folder also search every folder under Home (SearchHomeFolders)

SearchFiles and SearchWorkspaceFiles keep their wire shapes, so the iOS @ gets the new ranking with no app changes. nucleo-matcher is removed.

The cmd+K palette

Captured under Xvfb, so without the backdrop blur. The fixture has no engine, so it shows no file results.

Visual design is unchanged. It keeps the same card, rows, chat rows, highlight, fade and footer. The palette adds structure only:

  • Tabs: All · Threads · Commands · Files.
    • mod-1…4 switch tabs while the palette is open. Tab / Shift+Tab cycle them, and the palette always opens on All.
    • mod-1…4 are also the session jumps. The tab bindings live in a CommandPalette key context, so they win only while the palette is open. With the palette closed, mod-2 still jumps to session 2 (covered by GPUI tests).
    • gpui ranks context-free bindings at full focus depth, so the binding uses the predicate CommandPalette > PaletteSearch as well as CommandPalette.
  • Empty query:
    • Recent Files: the focused chat's last opened editor tabs, up to 5.
    • Quick Actions: the existing four, plus Archive thread when a chat is focused.
    • Recent Threads: by last activity, up to 5, excluding the focused chat.
  • With a query, sections appear in this order:
    • Threads: 5 under All, 50 in its tab.
    • Commands.
    • Files: 5 under All, 50 in its tab.
    • In Files: 5 under All, 100 in its tab, at most 3 per file.
    • Empty sections are hidden.
  • Thread ranking (shell/thread_search.rs) is modeled on the mobile client's scorer:
    • Every word must match.
    • Field weights: title 100, project 40, branch 30, device and PR 20.
    • A match that starts a word gets a +50% bonus.
    • Archived threads score half, and ties go to the most recent activity.
    • Side chats and subagent workers never appear.
  • Async sections:
    • Files and In Files load asynchronously. Answers to superseded queries are dropped by generation.
    • The highlighted row is kept by identity, so arriving results never move the selection.
    • While a request runs, or while the host is still building its index, section headers show "Searching…" or "Indexing…".
  • Enter:
    • A file opens in an editor tab, and a folder is revealed in the files tree.
    • A content match opens its file at the line. The byte column is converted to a character column, so the cursor lands correctly after é or emoji.

Engine: WorkspaceSearch

crates/engine/src/workspace_search.rs holds one fff file picker per working folder: repository, worktree or plain folder. It is keyed by the canonical root that WorkspaceFiles::resolve_target returns.

fff options

Option Value Why
Content (bigram) index off ~30% of a large repo's index memory and scan CPU. Plain grep still answers in ~20–30 ms worst case on a 28k-file repo.
Content cache 16 MB per index fff would size it up to 512 MB
mmap warm-up off —
Frecency DB SharedDb::noop() no LMDB files written
Watcher on (one per index) keeps the index current
Symlinks not followed same as before
Filesystem root refused —
Home folder allowed projectless chats and the new-project picker search it

fff's own tracing and panic hooks are not installed.

New RPCs. All are forwardable through the relay, so remote chats search on their host.

RPC Purpose
WarmWorkspaceSearch Start (or keep alive) a workspace's index; replies building / ready.
SearchWorkspaceContent Plain-text, smart-case content search. Returns path, line, column, preview, byte ranges, truncated and indexing. Default 100 matches, max 200, 3 per file; 2 s time budget.
SearchHomeFolders Fuzzy folder search under the host's home, for the new-project picker.
ReleaseHomeFolderSearch The picker is done with the home index.

Content queries are literal. The query goes through fff's raw-pattern API, which keeps whitespace, !, and backslashes verbatim.

Older hosts answer UnknownMethod:

  • Warm-ups are skipped silently.
  • The In Files section says the device needs an update.
  • Name searches skip their readiness probe.

Performance

Measured with crates/engine/examples/workspace_search_bench.rs (release build, Linux x86_64, glibc). Medians.

  • comet: this repository, ≈2.7k tracked files.
  • t3code: a ≈27.7k-file TypeScript monorepo.
Median per query comet before comet after t3code before t3code after
@ (SearchFiles, 8 results) 0.13–0.58 ms¹ 0.7–1.6 ms 1.0–31 ms¹ 1.0–3.6 ms
File tree search (≤200 results) 27.7–30.1 ms 0.7–2.6 ms 209–409 ms 1.8–4.4 ms
Tree search with "show ignored" (walk) 310–781 ms 237–248 ms 105–320 ms 91–109 ms
Content search (100 results, 3/file) — 0.5–2.3 ms — 1.1–21 ms
Index ready (warm page cache) — ~40 ms — ~0.3 s

¹ The old @ index was a 10-second cache rebuilt by a full walk on expiry; these numbers are cache hits only.

  • Content search without a bigram index:
    • Queries with many hits stay around 1 ms.
    • Rare or absent strings scan every candidate file: ~20 ms on t3code, ~0.3 s across a whole home folder.
  • Cold disk: the first scan is much slower. t3code took 13 s with a cold page cache. Searches never wait more than 300 ms for it; they answer from what is indexed and flag indexing.

Full numbers and reproduction steps are in docs/performance-workspace-search.md.

Memory: how it scales

An index's memory is roughly proportional to the number of indexed paths. The first index also pays a one-time cost for fff's global search and scan thread pools. All figures are process RSS on Linux.

Scenario Indexed paths RSS growth
One medium repo focused (comet) ~2.7k +14 MB index; ~+34 MB including first-use thread pools and search allocations
One large monorepo (t3code) ~27.7k +70 MB index; ~+86 MB after content searches
New-project picker in Home / projectless chat ($HOME) dotfiles and machine-state folders skipped +54 MB; ready in ~6 s, name search ~18 ms, content miss ~0.3 s
Seven live indexes (the cap): comet, t3code and five small/medium repos, opened one after another ~35k +164 MB total; t3code alone is 69 MB of it
After every index is evicted — +20–35 MB residual (see below)

Upper bound. At most 7 indexes, each with a content cache capped at 16 MB. The worst case is about seven indexes of your largest workspaces alive at once. For example, seven t3code-sized monorepos would be roughly 0.5 GB. Typical use (a few chats in one or two repos) stays in the tens of MB.

The residual after eviction is not an index that stayed alive.

  • It is fff's process-wide thread pools plus glibc per-thread arenas.
  • It plateaus: repeated create/evict cycles stop growing after about four cycles.
  • On glibc, eviction calls malloc_trim(0) to hand freed pages back.
  • macOS (the app uses mimalloc) and Windows were not measured.

How the index memory compares to before. The old @ cache was 3.6 MB for comet and 12 MB for t3code. But it was rebuilt by a full walk every 10 seconds, and the tree search had no index at all.

Cleanup mechanisms and index lifecycle

          first warm-up / search for a root
                       │
                       ▼
   ┌────────────── created ──────────────┐
   │  background scan + file watcher     │──► searches wait ≤ 300 ms for the scan,
   │                                     │    then answer partially (`indexing`)
   ▼                                     │
 live ◄── every search or warm-up refreshes `last_used`
   │
   ├── idle ≥ 15 min ─────────────► evicted by the reaper (checks every 60 s)
   ├── an 8th index is created ───► the least recently used index is evicted at once
   ├── new-project picker closes ─► home index released, unless a chat warmed it
   └── engine graph dropped ──────► dropped with it (scan cancelled, watcher stopped)

Creation

  • Warm-up on focus: the UI calls WarmWorkspaceSearch once when a chat gains focus. There is no heartbeat and no pinning, so refocusing a chat warms it again.
  • Opening the files tree also warms (fire-and-forget).
  • Any search creates its index if needed: @, the tree, the palette, or the home picker.

While scanning

  • A search waits at most SCAN_WAIT = 300 ms, then answers from what is indexed and reports indexing.
  • The palette and the picker refresh partial results until the index is ready:
    • Content search retries after 0.5 s, then 1 s, then every 2 s. Each refresh is a full grep, so it backs off.
    • File names use WarmWorkspaceSearch as a readiness probe, because SearchWorkspaceFiles is list-shaped and has no indexing flag. Once a probe answers ready for that workspace, later keystrokes in the same palette session skip the probe, which saves a round trip per keystroke on remote hosts.
    • Home folders retry after 0.5 s, then 1 s, then every 2 s.
    • All retries stop on a new query, an error, or when the palette or picker closes.

Idle eviction

  • A reaper thread starts lazily with the first index.
  • It wakes every REAPER_INTERVAL = 60 s and drops indexes unused for IDLE_EVICT = 15 min, so an index lives 15–16 minutes after its last use.
  • The reaper holds only a weak reference and exits when WorkspaceSearch is dropped.

Capacity eviction

  • At most MAX_LIVE = 7 indexes are alive at once.
  • Creating an eighth immediately evicts the least recently used.
  • Each index owns a file watcher, so the cap also bounds watcher threads and Linux inotify instances (see below).

Explicit release

  • ReleaseHomeFolderSearch drops the home index when the new-project picker leaves Home or closes.
  • It is skipped when a chat warmed that index, because projectless chats live in Home.

What eviction does

  1. Cancels any running scan.
  2. On a background thread, takes the picker out under its write lock, which waits for in-flight searches.
  3. Stops the watcher and drops the index.
  4. On glibc, calls malloc_trim(0).
  5. Logs workspace search index evicted with the reason (idle, capacity, released), file count and arena bytes.

Creation and the end of the first scan are logged too, with root, files, scan time and arena bytes.

Freshness

  • The index follows the filesystem through fff's watcher, change by change.
  • fff rescans on its own when the OS reports lost events, when a .gitignore / .ignore changes, or when a burst is too large to apply incrementally.
  • There is deliberately no rescan after every agent turn. A forced full rescan costs ~0.2 s of CPU on comet and ~1–1.7 s on t3code per turn, and fff already self-heals.

UI-side state

  • Palette requests and retries are tasks owned by the palette. Closing it or typing a new query drops them, which cancels in-flight requests.
  • Recent Files keeps at most 20 paths per chat, in memory only.

Platform compatibility

Linux macOS Windows
Watcher backend (via notify 9) inotify FSEvents ReadDirectoryChangesW
What a watcher costs 1 inotify instance + 1 watch per directory 1 stream 1 handle
Limit that matters max_user_instances, often 128 per user, shared by every app none in practice none in practice
Lost events queue overflow → fff rescans "must scan subdirs" → fff rescans buffer overflow → fff rescans
Freed memory malloc_trim after eviction mimalloc (not measured) system allocator (not measured)
Measured here ✅ all numbers above ⚠️ CI only ⚠️ CI only

Linux

  • Measured on Fedora x86_64.
  • The 7-index cap matters most here: on the development machine ~100 of 128 inotify instances were already in use by other apps.
  • If a watcher cannot start, the index still answers searches, but it does not see changes until it is evicted (see Known limitations).

Windows

  • Name queries: typing \ in a name or path query is treated as / when the host is Windows. On POSIX hosts a backslash stays a literal filename character.
  • Extended and UNC paths (\\?\C:\…, \\?\UNC\server\share\…):
    • Browser paths are simplified with dunce, only when that is lossless.
    • The UI compares folder paths with a normalized key that handles reserved names (NUL, COM1), trailing dots and long paths.
  • Drive letters: case is ignored (c: = C:).
  • Folder names: case is kept, because Windows folders can be made case-sensitive with fsutil setCaseSensitiveInfo.
  • fff paths: fff stores /-separated relative paths everywhere, and canonicalizes base paths with dunce, so no \\?\ prefix leaks into results.

macOS

  • FSEvents watches each index root recursively with a single stream.
  • mod-1…4 map to ⌘1…4.

Builds

  • fff-search pulls C builds of libgit2-sys, libz-sys and lmdb-master-sys. A C compiler is required on every platform; the existing macOS and Windows CI workflows cover this PR.
  • 53 new lockfile entries, most of them Windows target shims. notify 9.0.0-rc coexists with the engine's notify 7.

Behavior changes worth knowing

  • Ranking follows fff: fuzzy and typo-tolerant, with filename, git-recency and modification-time boosts. The top results are better. The tree's 200 slots now include a long tail of weak matches, where the old substring ranking returned fewer rows.
  • An empty @ query lists the chat's featured paths first, then recently changed files. It used to list the shallowest paths.
  • Not indexed: ignored files, symbolic links, and dotfiles outside git repositories. .git is never indexed. "Show ignored" in the tree keeps a direct disk walk with a plain substring match.
  • Content search skips binary files and files over 10 MB.

Known limitations / follow-ups

  • Content case-insensitivity is ASCII-only (an fff limitation in all its grep modes): café does not find CAFÉ. A workaround is to send the case variants of non-ASCII letters as extra patterns; this is also worth reporting upstream.
  • A watcher that fails to start (for example, inotify exhausted) is not detected. The index stays stale until eviction. Follow-up: check is_watcher_ready() and rescan on the next search.
  • Each index runs its own fff watcher, alongside the engine's FsWatchHub and the file tree's watcher. Feeding fff from the hub (watch: false + handle_create_or_modify / remove_file_by_path) would watch each folder once.
  • Engine shutdown does not release indexes explicitly; they are freed when the engine graph drops.
  • Not measured: binary size, clean build time, and memory on macOS and Windows.
  • Not exercised natively: mod-1…4 precedence on macOS, Windows and Wayland. It is covered by GPUI tests on Linux.

Testing

  • cargo check --workspace --all-targets --locked passes.
  • cargo test -p zeron-engine --lib: 488 passed.
  • Engine integration tests: device_routing (11), m5_repos_diffs_terminals (37) and workspace_files (8) pass. device_routing exercises SearchWorkspaceFiles, WarmWorkspaceSearch and SearchWorkspaceContent through the relay.
  • cargo test -p zeron-proto -p zeron-rpc passes.
  • cargo test -p zeron-ui --lib: 1760/1762. The two failures are the pre-existing layout-dependent markdown tests, which are flaky under parallel load; all 106 markdown tests pass with --test-threads=1.
  • Clippy reports nothing new in the changed code.

New coverage

  • WorkspaceSearch: creation; directory-only search; the watcher picks up new files; idle and LRU eviction; a warmed index survives release; content search skips binaries and caps per file; blank queries.
  • Content search: literal spaces, tabs, ! and backslashes; smart case; UTF-8 byte ranges; query validation.
  • File tree search: fff path; "show ignored" walk with a bounded heap; Windows separators; literal POSIX backslashes.
  • Palette:
    • tabs and mod-1…4 versus session jumps;
    • empty-state sections; scored threads;
    • stale answers dropped; selection kept while results arrive;
    • refresh-while-indexing with back-off; probe skipping once ready;
    • older-host messages; activation of file and content rows.
  • Editor navigation: byte-to-character columns after é, emoji, tabs and combining marks.
  • Home picker: extended Windows and UNC paths, and drive-letter case.

Manual checks

  • Palette visuals checked under Xvfb (screenshots above).
  • Not yet checked: native macOS, Windows and Wayland; a remote chat end to end; RSS of the full app over time.
Commits (37)
  • build: add fff-search
  • test(engine): add a workspace search benchmark
  • feat(engine): add WorkspaceSearch on top of fff
  • feat(engine): serve SearchFiles (@) from fff
  • feat(engine): serve SearchWorkspaceFiles (file tree) from fff
  • chore: drop nucleo-matcher
  • feat(rpc): add WarmWorkspaceSearch
  • test(ui): add the missing images field to a ToolItem fixture
  • feat(ui): warm workspace search on chat focus
  • feat(rpc): add SearchWorkspaceContent
  • feat(ui): scored thread search for the command palette
  • refactor(ui): split the command palette into sections and tabs
  • feat(ui): command palette tabs with mod-1…4
  • feat(ui): empty-state sections in the command palette
  • feat(ui): ranked thread search in the command palette
  • feat(ui): search file names from the command palette
  • feat(ui): search file contents from the command palette
  • perf(engine): return an evicted index's memory to the allocator
  • perf(engine): cap each search index's content cache at 16 MB
  • docs: workspace search on fff
  • feat(rpc): add SearchHomeFolders
  • feat(ui): search folders under home when adding a project
  • perf(engine): index workspace paths only, grep contents without a bigram index
  • perf(engine): keep idle search indexes 15 min, up to 7, warmed once on focus
  • fix(ui): refresh palette content search while indexing
  • fix(engine): preserve literal workspace content queries
  • fix(ui): refresh palette file search while indexing
  • fix(ui): refresh home folder search while indexing
  • fix: handle extended Windows and UNC paths in home folder picker
  • fix(ui): convert content search byte columns for editor navigation
  • fix(engine): normalize Windows separators in name searches
  • fix(ui): back off palette content refreshes while indexing
  • perf(ui): skip the palette's index probe once the workspace is ready
  • fix(ui): match Windows folder paths regardless of drive letter case
  • docs(ui): describe the home index release without pins
  • two merges of main (Add a repository-scoped PR board and native inspection #484, Queue polish, and drag a chat from the sidebar to mention it #855)

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

jsgrrchg and others added 9 commits October 9, 2026 23:55
…ce-search-implementation

# Conflicts:
#	Cargo.lock
…ch-fff

# Conflicts:
#	crates/ui/src/shell/files_panel.rs
Engine
- Drop content matches reached through a symlink, and name results
  through a symlinked folder (ReadWorkspaceFile's rule); fff's watcher
  follows links created after the first scan.
- @ and file-tree name searches wait up to 2 s for a fresh index, since
  their answers can't report that it is still building.
- Evict the LRU index only after its replacement is created, and report
  "indexing" (not a final empty answer) when an index is released mid-search.
- File-name search accepts an empty query (not with "show ignored"),
  listing the workspace's files by the index's recency.
- Home folder results are spelled from the session's home, not its
  canonical path, so a symlinked home doesn't duplicate projects.

Palette
- The Files tab and All's Recent Files list opened files, then the
  workspace's recency list, when there is no query.
- Arrowing onto a section's first row scrolls its header into view.
- Tabs use the shared tab chip; shortcut legends are plain sidebar-style
  text set in the system font (the UI font lacks ⇧ ↵); footer keys keep
  their caps, with icon glyphs for arrows and return.
- Leaving for the PR board releases the new-project home index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@wingleeio wingleeio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security-audited: the fff-search dependency tree is crates.io-only with no network/telemetry, and the new search RPCs resolve roots through the same chat/space entitlement as ReadWorkspaceFile. Pushed review fixes (8e53b29): symlink confinement for search results, safer index eviction, a longer first-scan wait for @/file-tree, the palette's default Files list, and palette polish. CI green. Thanks José!

@wingleeio
wingleeio merged commit 3487b50 into zeronsh:main Oct 10, 2026
23 checks passed
@jsgrrchg
jsgrrchg deleted the feat/workspace-search-fff branch October 10, 2026 22:28
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 11, 2026
…acklog window

Source: .scratch/upstream-drift/2026-10-11.md. Tickets 01-18 actionable
(quick ports, chips supersedes wave-6 03, the sidebar series 06-09, the
older missed fixes 13-16, parity verification 17-18); 19 is the GPUI pin
sync gating 20 (zeronsh#856) and 22 (zeronsh#813 Windows browser); 21 (zeronsh#484 PR board)
PARKED on a product decision.
hoangvu12 added a commit to hoangvu12/roboco that referenced this pull request Oct 22, 2026
…me is never indexed

Upstream 1bc74d5 (zeronsh#876): workspace search indexed the
home folder for projectless chats, and fff's post-scan opens every
file up to 10 MB to sniff binaries — downloading every OneDrive
cloud-only file under home on Windows (iCloud/ File Provider on macOS).
Two halves, one hygiene fix:

- New projectless chats run in their own folder,
  <data dir>/scratch/<date>-<id> (~/.roboco/scratch/… for the default
  data dir), as t3code does under ~/.t3/scratch. The cwd is stored
  home-relative (proto::projectless: SCRATCH_ROOT, scratch_cwd,
  is_home_cwd/is_scratch_cwd/is_projectless_cwd); the host expands and
  CREATES the folder where the run spawns (sessions dispatch), the
  terminal opens (resolve_open_terminal_cwd + resolve_chat_cwd), and
  the file browser (workspace_files resolve_target). createChat and
  claim_chat mint the scratch folder (workspace_host
  new_projectless_cwd/hosts_scratch_folders — this engine always
  hosts them; a peer must advertise projectless-scratch-v1, now in
  capabilities and regenerated wire constants). Chats from before
  keep `~`, so their harness sessions still resume; runs from older
  clients that send `~` follow the chat's row (doc_host redirect
  after claim_chat). view::project_label/chat_location treat scratch
  cwds as "No project".
- home_index.rs: names-only walk of the host's home for the chats
  that still run in `~` and the new-project folder search — cloud
  roots (OneDrive*, iCloud Drive, Dropbox, Google Drive, Box, …),
  machine folders (Library, AppData, Applications), hidden and
  dependency folders, and cloud placeholder folders are skipped; no
  content search there.

Held for wave-7 ticket 20 (zeronsh#856 workspace search): the upstream
workspace_search.rs hunks (+183) that CONSUME HomeIndex — roboco
doesn't hold the file yet. home_index.rs ships with its tests and
the shared name-search vocabulary (NameKinds/NameMatch/
normalize_name_query) defined locally, with an allow(dead_code)
transitional note: ticket 20's port should unify them into
workspace_search.

Excluded upstream hunks: crates/client (cloud client library —
roboco has no counterpart); crates/ui (composer/files/shell/state —
UI lane); crates/mcp tools.rs (out of this engine ticket's scope —
MCP-created chats send no cwd, so the engine's createChat/claim
already mints the folder, and MCP runs send `~`, which the doc_host
redirect follows); docs/performance-workspace-search.md (documents
the workspace-search path roboco doesn't hold yet). The upstream
device_routing relay test (+23) is adapted engine-locally as
projectless_terminal_runs_in_the_chats_scratch_folder.

Tests: projectless.rs reworked (CreateFirst/CommandFirst/Legacy
modes, expected_cwd, terminal scratch resolution); workspace_files
+ rich_composer_delivery scratch assertions; proto projectless/view/
workspace suites; wiregen --check green (constants regenerated).
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.

2 participants