Skip to content

feat: Find in Files — search file contents across the workspace from a ⌘⇧F window and open the hit at its line - #933

Open
danyaberezun wants to merge 7 commits into
global-search-continuefrom
global-search-find
Open

danyaberezun wants to merge 7 commits into
global-search-continuefrom
global-search-find

Conversation

@danyaberezun

@danyaberezun danyaberezun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

ThinkRail could find a file by name (the palette's / scope) but nothing searched file contents: finding where a symbol or a string occurs in the workspace meant a terminal and grep, and opening the hit at its line meant scrolling by hand. #644 asked for workspace-wide text search behind ⌘⇧F — results grouped by file, one row per matching line, a click opening the file at that line, .git / binary / oversized files skipped, a stated cap. Its proposed design is taken as the approved v1 and is not re-asked; the interview closed what it left open (own window with a code preview, smart-case + Aa / W, the palette's file set, 2 000-line cap, ↵ preview tab / ⌘↵ kept tab, the window restoring on the next ⌘⇧F). Design in the originating chat's TASK-find-in-files.md.

Sixth PR of the global-search stack; stacked on #888 so it reuses what the palette's host search already owns (one search root, per-client seq superseding, addressed pushes, the command catalog) instead of building a second copy.

Rebased on main (df7cb6d2) with the stack. TEXT_SEARCH_PROTOCOL_VERSION is v102 (main v98, search v99, chats v100, continue v101).

Approach

  • git grep on the host, streamed. A text provider in the server's search module runs one git -c grep.column=false -c grep.fullName=false grep --no-color -n -z -I --fixed-strings [-i] [-w] --untracked --exclude-standard -- . <hidden folders> per search in the same root the palette's / file names use (the active worktree; Project Home → the project's Default record). That is the palette's file set minus binaries (-I) — node_modules, .hg and .svn excluded as pathspecs, as the palette hides them — with files over 1 MB dropped after one stat, lines cut to 400 characters around the first match, and the process killed at 2 000 matching lines (done.reason = "limit"). A chat without a project (Chats without a project: a built-in Chats container with one scratch folder per chat #887) is never a repository and never runs git: its folder's walked file list is read in-process. Rejected: ripgrep through pi's ensureTool (not a public export), bundling ripgrep (+~5 MB per artifact), rg from PATH (hermetic PATH), an in-process TypeScript scan (re-reads every file per keystroke and re-implements .gitignore), and a text kind inside search.query (ranked ≤ 50-per-group groups cannot carry 2 000 lines grouped by file and cannot stream). On this repository a symbol search answers in ~40 ms.
  • Answer at once, stream to one socket. search.text { seq, query, context, caseSensitive, wholeWord } returns { seq } immediately (or { seq, superseded } for a seq older than the client's latest); the matches follow as TextSearchBatches on the addressed search.textResults push through a setSearchTextPublisher seam server.ts installs — the terminal / MCP-login pattern, never a broadcast. A newer search.text from the same client aborts the older process (cancelling the bounded stream kills the process tree — streamBounded already did that, so no new signal plumbing was needed), and an aborted search emits nothing more.
  • Smart-case is the client's. A capital letter in the query, or the Aa toggle, makes the search exact; the host only ever sees an explicit caseSensitive. W adds -w; the host recomputes match ranges itself with a Unicode case-insensitive regex over the original text (git only reports lines), so highlighting is exact. Git folds only ASCII case under LC_ALL=C, so a case-insensitive query with a non-ASCII letter is sent as an extended regex spelling out each letter's case variants.
  • A window beside the palette, not inside it. ⌘⇧F / Ctrl+Shift+F (one owner: useGlobalHotkeys; double Shift stays the palette's) opens a new findInFiles module's dialog over the workbench: the query with Aa / W (⌥C / ⌥W), the scope and counts, results grouped by file with the match marked, a Monaco preview of the selected match below, and a keycap footer. The palette's > commands gain Find in files… with the chord as its display shortcut. The module keeps the query, toggles, results and selection in a module-level store so the next ⌘⇧F restores them (results from another workspace are hidden, not cleared, and the same query re-runs there); it imports neither store nor panels — shell/FindInFilesHost injects what ↵ / ⌘↵ do and how the preview renders. The window tells ready / offline / unsupported apart, so against an older host it says the host needs an update rather than "offline", and the palette hides the command.
  • Open at a line through one renderer prop. ResourceViewProps.reveal ({ line, column?, length?, tick }, exported as ResourceReveal with SOURCE_RENDERER_ID) is honoured by the code renderer's two views — Pierre scrolls with its virtualizer's revealLine and marks the line; Monaco uses revealLineNow on a fresh mount (the review-focus rule) and selects column..column+length — and ignored by the rich ones. openTabs.openFileAtLine is the ordinary openFileInTab followed by the store's new revealFileTab, which stamps the tab's reveal (a tick repeats the same line) and switches the tab to the source renderer so a Markdown or HTML file opens at its line rather than as its rendered document. ResourcePreview forwards the same reveal, so the window's preview and the opened tab share the behaviour.
  • One keycap / highlight vocabulary. The palette's Keycaps, Hint and range highlight moved to components (Keycaps, KeyHint, highlightRanges) and both surfaces use them; the footer keys go through commands' shortcutKeys, so Windows/Linux read Ctrl+↵ where macOS reads ⌘↵.

Changes

  • packages/contracts: PROTOCOL_VERSION = 102 = TEXT_SEARCH_PROTOCOL_VERSION; search.text method, search.textResults channel; TextSearchMatch / TextSearchFile / TextSearchBatch / TextSearchEnd, TEXT_SEARCH_LIMITS (2 000 lines, 1 MB, 400 chars, 2–200 query chars); error codes TEXT_SEARCH_TIMEOUT / TEXT_SEARCH_FAILED; the v101 pin now asserts >=.
  • packages/server: search/text.ts (args, NUL/newline record parser with a capped record buffer, ranges, line cut, the streaming search), SearchService.text (per-client seq + AbortController, forgetClient aborts), host/search.ts publisher seam, host/server.ts (addressed send ≥ v102, cleared on close), handlers.ts. Tests: search/text.test.ts on a temp repository — tracked and untracked files, ignored / binary / oversized files never match, the limit, case and whole-word flags reaching git, empty and non-git folders, abort emits nothing more, supersede per client.
  • packages/resource-renderers: ResourceReveal, ResourceViewProps.reveal, ResourcePreview.reveal, SOURCE_RENDERER_ID; Pierre and Monaco views honour a reveal.
  • apps/web: new findInFiles/ (SPEC, barrel, state.ts store, runner.ts controller, FindInFilesDialog); shell/FindInFilesHost.tsx, Mod+Shift+F in useGlobalHotkeys, files.findInFiles in paletteCatalog (gated on supportsTextSearch + a scope); store FileTab.reveal + revealFileTab; panels/openTabs.openFileAtLine, FilePane forwards reveal; transport.supportsTextSearch; components/Keycaps.tsx, components/MatchHighlight.tsx (the palette now imports them); 25 findInFiles.* keys + commands.files.findInFiles in en.json. Tests: findInFiles/state.test.ts, runner.test.ts (mocked transport), the hotkey table, revealFileTab.
  • Specs: new apps/web/src/findInFiles/SPEC.md; apps/web/SPEC.md (module table, edges), shell, commands (coverage table), store, panels, components, transport, search (server: the Text section, host composition, the fifth addressed channel), contracts, resource-renderers, e2e/SPEC.md; README gains the capability sentence.
  • e2e: find-in-files.spec.ts (5 scenarios) — chord, grouping and counts, the preview, ↵ opening Monaco at the line with the match selected, the restored window; smart-case and the toggles, the limit banner, "Nothing found"; the palette command from Project Home searching the Default workspace and entering it, double Shift inert over the window; an older host (welcome rewritten to v101) hides the command and the window says so with no search.text on the wire.
  • Review round 1 (two reviewers and the bot's 13 threads; fixed in 99897ac, 2d627b7, 3ad58ee, 6b7a26d):
    • every match git finds is kept: user grep.* config overridden, Unicode case-insensitive ranges (a length-changing İ no longer drops the line), the whole-word retry one character later, non-ASCII case variants asked of git under LC_ALL=C, a record holding a whole line of any kept file, a newline in a file name;
    • one file set with the palette (hidden folders excluded as pathspecs), and no git in a chat's folder (its walked files read in-process);
    • failures logged with TEXT_SEARCH_TIMEOUT / TEXT_SEARCH_FAILED; the first match sent at once; a search cancelled when its socket closes;
    • the window asks again after a reconnect or a failed search, is a combobox with per-file groups and a live status, and its channel is never replayed;
    • open at a line: the latest hit wins while the file's read is in flight, a far hit comes into view (Monaco reveals immediately — the smooth scroll was cut short), and a reveal is consumed so a tab keeps its reading position;
    • lone-CR files keep git's newline-based line numbers, documented as a v1 limit.

Deferred, as #644 scopes v1: regex, include/exclude globs, replace, find inside rendered previews and diffs (#599), a permanent search panel, search history.

Screenshots

Before (the same workbench on the base — ⌘⇧F does nothing there):

before — the workbench, no window

After — ⌘⇧F, renameSession typed, the second match selected and previewed:

after — the Find in Files window

After — ↵ opened the file as a preview tab at line 1 with the match selected:

after — the file opened at the line

Testing

On the final head (6b7a26dba, rebased onto main df7cb6d2):

  • bunx turbo run typecheck --filter='!@thinkrail/desktop' → 24/24; bun run lint (one existing warning), check:deps, check:boundaries, check:seams, check:spec-surface, check:i18n, check:compiler → green; apps/web bun test → 2099 pass
  • bun run test → 25/25 tasks
  • bun run e2e (full no-agent suite, 8 shards) → all 699 passed. It ran in a private network namespace (loopback plus an empty bridge): this machine's Wi-Fi re-associates every ~5 minutes and Chromium then aborts in-flight loads with net::ERR_NETWORK_CHANGED, which fails plain main too.

Earlier (c3d5051df, on #888 5b93701a4 and main 940d15ca5; the same tree as e789849b0, where these runs were made):

  • bunx turbo run typecheck --filter='!@thinkrail/desktop' → 24/24; bun run lint (one existing warning), check:deps, check:seams, check:boundaries, check:spec-surface, check:i18n, check:compiler → green
  • bun run test → 25/25 tasks; packages/server/src/search/text.test.ts → 14 pass, and each round-1 test fails with its fix removed (git config, hidden folders, the record cap, non-ASCII case variants, failure logging, a chat folder listed afresh); the web findInFiles, openTabs, transport and store reveal tests likewise fail without their fixes
  • bun run e2e e2e/find-in-files.spec.ts e2e/search-palette.spec.ts e2e/chats.spec.ts --repeat-each=3 → 87 passed
  • bun run e2e (full no-agent suite, 8 shards) → all 692 passed. The suite ran in a private network namespace (loopback plus an empty bridge): this machine's Wi-Fi re-associates every ~5 minutes and Chromium then aborts in-flight page loads with net::ERR_NETWORK_CHANGED, which fails plain main too
  • The new far-hit scenario fails on the PR's previous head (0d22c1256) too: the hit at line 150 never came into view

Related issues

Closes #644. Part of the global-search stack (#804 → #805 → #806 → #887 → #888 → this).

Checklist

  • Fast gates pass: bun run lint, bun run typecheck, bun run test
  • E2E suite passes for app-affecting changes (bun run e2e, or bun run e2e:full when touching agent behavior)
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

@jetbrains-air jetbrains-air Bot 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.

Requesting changes — please address the blocking inline finding.

Comment thread apps/web/src/findInFiles/FindInFilesDialog.tsx Outdated
Comment thread packages/resource-renderers/src/support/MonacoEditor.tsx Outdated
Comment thread apps/web/src/panels/openTabs.ts
Comment thread packages/server/src/search/text.ts
Comment thread packages/server/src/search/text.ts Outdated
Comment thread packages/server/src/search/text.ts
Comment thread packages/server/src/search/text.ts Outdated
Comment thread packages/server/src/search/text.ts Outdated

@jetbrains-air jetbrains-air Bot 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.

Requesting changes — please address the blocking inline finding.

Comment thread packages/server/src/search/text.ts Outdated
Comment thread packages/server/src/search/text.ts Outdated

@jetbrains-air jetbrains-air Bot 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.

Requesting changes — please address the blocking inline finding.

Comment thread packages/server/src/search/text.ts Outdated

@jetbrains-air jetbrains-air Bot 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.

Requesting changes — please address the blocking inline finding.

Comment thread packages/server/src/search/text.ts Outdated
Comment thread packages/server/src/search/service.ts
@danyaberezun

Copy link
Copy Markdown
Collaborator Author

@jetbrains-air please review — all 13 threads addressed: every match git finds is kept (git config, Unicode case incl. non-ASCII pairs, whole-word retry, record cap, newline in names), the palette's file set (hidden folders) and no git in a chat folder, failures logged with codes, the window re-asks after a reconnect or failure and is a combobox, open at a line takes the latest hit and keeps a tab's reading position; lone-CR numbering documented as a v1 limit

@danyaberezun

Copy link
Copy Markdown
Collaborator Author

@jetbrains-air please review the current head c3d5051df — all 13 earlier threads are addressed and resolved (every match git finds is kept, git grep runs with a fixed column/full-name config, hidden dirs excluded as pathspecs, chat folders scanned in-process, timeouts and failures reported, open-at-line reveals once).

@danyaberezun

Copy link
Copy Markdown
Collaborator Author

@jetbrains-air please review the current head fccf7f839 — rebased onto main 25ac7921 (#1004), so TEXT_SEARCH_PROTOCOL_VERSION is now v100; the 13 earlier threads stay addressed and resolved (every match git finds is kept, a fixed git grep config, hidden dirs as pathspecs, chat folders scanned in-process, timeouts and failures reported, open-at-line reveals once).

…r file name, and keep git out of a chat

Find in Files dropped or garbled matches git had found, and reported the search complete:
- a user's `grep.column` / `grep.fullName` reshaped the output the parser reads; both are now
  overridden with `-c` for the one command;
- the host recomputed ranges with a lowercase that changes length (`İ`), throwing the whole line away,
  and a whole-word candidate rejected at a word edge skipped its overlapping neighbour; ranges now come
  from a Unicode case-insensitive regex over the original text, retried one character later after a
  rejected edge, as git's `-w` does;
- git folds only ASCII case under `LC_ALL=C`, so `äpfel` missed `Äpfel`; a case-insensitive query with
  a non-ASCII cased letter is now sent as an extended regex spelling out each letter's case variants;
- a 64 KiB record cap cut long lines of small files before matching; a record now holds a whole line of
  any file under the size limit;
- a newline in a file name split the record; the path and line number now end at their NULs and only
  the text ends at the newline.

The search now shares the palette's file set: `node_modules`, `.hg` and `.svn` are excluded as
pathspecs (and dropped if they slip through), and a chat's folder is never handed to git: the walked
file list the palette already uses is read in-process, skipping oversized and binary files, so a chat
stays git-free and no enclosing or global ignore rule hides its files.

A failed search is logged through `logger("search")` with `TEXT_SEARCH_TIMEOUT` or
`TEXT_SEARCH_FAILED` (new `WsErrorCode`s, never git's stderr); git's no-match exit and cancellation
are not failures. The first match is sent at once and later ones within `flushMs`, even while git is
still scanning, and a client's search is cancelled when its socket closes.
…h, and announce its matches

The window skipped a request identical to the last one asked, even when that search could never
finish: its batches only reach the socket that asked, so a reconnect mid-search left it "Searching…",
and a search that ended `failed` kept its key, so the same query was never asked again. The shell now
hands the dialog the welcome generation: a request asked on an earlier connection that is still
searching is asked again with a fresh `seq`, while a finished one keeps its results; a `failed` end
forgets the key like a rejected request. `search.textResults` is never replayed to a late subscriber,
as the contract says.

The window is now a combobox over a listbox of one labelled group per file, with option ids for
`aria-activedescendant` and a polite live status, so a screen reader hears the selected match and the
counts while focus stays in the field.
…'s reading position after its reveal

A second open at a line while the file's read was in flight returned at once, found no tab to reveal,
and the first hit won; `openReadTab` now hands every caller the in-flight read, so each reveals after
the tab is installed, in call order. A tab's reveal stayed on the tab and every remount scrolled back
to the hit over the saved view state; the code views now report the tick they applied
(`onRevealHandled`) and the pane clears it (`clearFileTabReveal`). Monaco reveals with an immediate
scroll every time: a smooth scroll to a far line was cut short by the next layout.
…eeze the clock to prove silence

The open-at-a-line scenario only revealed lines near the top, so a reveal that never reached a far line
passed; a kept tab now opens at line 150, keeps the position the user scrolled to across a tab switch,
and the older-host case plays its debounce out on a paused page clock instead of a 500 ms sleep.
@danyaberezun

Copy link
Copy Markdown
Collaborator Author

@jetbrains-air please review the current head 6b7a26dba — rebased onto main df7cb6d2: TEXT_SEARCH_PROTOCOL_VERSION is now v102, and setSearchTextPublisher(null) sits beside main's configureSurveySource(null) in the server teardown; the 13 earlier threads stay addressed and resolved.

This branch has not been deployed

No deployments
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.

Search file contents across the workspace

1 participant