Skip to content

Add global search across Markdown Files (#117)#159

Open
zcuric wants to merge 2 commits into
bholmesdev:mainfrom
zcuric:feat/gh-117-global-search
Open

Add global search across Markdown Files (#117)#159
zcuric wants to merge 2 commits into
bholmesdev:mainfrom
zcuric:feat/gh-117-global-search

Conversation

@zcuric

@zcuric zcuric commented Jul 10, 2026

Copy link
Copy Markdown

Closes #117.

Adds an MVP global search palette to Hubble Desktop: press Cmd+P (or File → Go to File…) to find a Markdown File by name, by path, or by a phrase inside it, then Enter to open it.

The issue asked for the indexing decision to be made as part of the work, so this PR contains both the spec that records it and the implementation that follows it.

The decision: naive on-demand search, no index

Recorded in specs/gh-117/TECH.md. Three reasons, in descending order of how costly they'd be to get wrong:

  • A desktop index would fight ADR-0008. An index needs invalidation, and the only cheap invalidation signal is a recursive watcher — precisely what ADR-0008 rules out. Instead, content search reads the candidate paths the sidebar snapshot already holds in the renderer, so main never re-walks the folder. Search sees exactly what the sidebar sees, goes stale the same way, and refreshes on the same focus trigger. No second view of the folder, no cache to invalidate, no watcher.
  • Web wouldn't benefit from one. The Convex files table already stores content inline and getFilesByWorkspace returns full rows — which is why loadPath in apps/www does a .find() rather than a fetch. Every note body is already in client memory. A .searchIndex() would build a server-side index to search data the client already has.
  • Pagefind is the wrong shape. It's a build-time indexer over built HTML. Hubble has no build step over user content, and that content changes on every keystroke. Worth stealing its ranking and excerpt presentation, not its architecture.

What changed

  • packages/ui/src/lib/fuzzy.ts — path-aware fuzzy scoring (exact > prefix > substring > subsequence, matching the existing command menus) plus matchRanges so the palette can emphasize where a query matched. A basename hit outranks a parent-folder-only hit.
  • packages/ui/src/components/GlobalSearchPalette.tsx — shared cmdk palette. Name matching is synchronous; content search is injected as a searchContents prop, debounced 150 ms and gated at 3 characters. A file matching both ways appears once, under Files.
  • apps/desktop/src/lib/searchContent.ts — literal, case-insensitive line matcher with windowed excerpts. Deliberately not a regex: user input would need escaping anyway, and a regex dialect is a promise we don't want to make for an MVP search box.
  • apps/desktop/electron/main.tsdesktop:search-file-contents IPC. Concurrency-pooled reads, assertGranted on every path, 2 MiB per-file cap, 50-file result cap surfaced in the UI rather than silently truncated. A superseded search is abandoned by comparing request ids between files, since an AbortSignal can't cross IPC.
  • Desktop wiring: CmdOrCtrl+P in the existing keymatch chain plus a File menu item, both open-only and idempotent (this is what CmdOrCtrl+Shift+O already does). Selecting a result goes through the existing loadPath chokepoint, so search results participate in document history for free.

Performance

specs/gh-117/TECH.md originally carried an estimate; it now carries a measurement. Over 2,000 markdown files totalling 16 MiB, concurrency 8, warm cache:

query matching files median
no match (full scan, worst case) 0 44 ms
rare needle 20 49 ms
common needle capped at 50 1 ms

The worst case is a full scan at ~45 ms, comfortably inside the 150 ms debounce. A common needle is faster, not slower, because the result cap short-circuits the walk almost immediately. If a much larger corpus ever misses this budget, the escalation is a stat-validated content cache in main — strictly cheaper than an index, and still no watcher.

Two base-ui bugs found while testing

Both share one root cause — Dialog.Popup stays mounted after close — and neither was caught by tests. Both were found by driving the running app over CDP.

  1. Ghost clicks. At opacity: 0 the popup still hit-tests. After opening the palette even once, a real click in the middle of the editor landed on an invisible result row and opened whichever file sat under the cursor (reproduced: a click meant for the text navigated to Project Ideas). Fixed with data-[closed]:pointer-events-none on the popup and the backdrop.
  2. Dead second open. autoFocus fires only on mount, so the second Cmd+P left the caret in the editor: the palette rendered but swallowed every keystroke and Enter selected nothing. Fixed with base-ui's initialFocus={inputRef} on Dialog.Popup, which runs on every open. Note that a manual focus() in an effect is not sufficient — it races base-ui's own focus management and lands non-deterministically (it passed once, then failed on the next run).

packages/ui/src/primitives/modal.tsx has both latent behaviors — I confirmed the Settings modal also stays mounted and captures clicks. It's harmless there today because it's a small centered dialog, but it's the same trap waiting for the next full-width overlay. Left for a separate change and recorded in the spec's risks.

Testing

  • Unit (22 new): fuzzy.test.ts covers the score ladder, separator normalization, basename preference, and matchRanges offsets including the subsequence case. searchContent.test.ts covers line numbers, excerpt windowing at line start/end/mid-line, offset correctness after trimming indentation, CRLF, the per-file match cap, and blank queries.
  • Full suites: 64 UI + 73 desktop tests pass. pnpm check and pnpm build:desktop are green.
  • End-to-end in the running app (via CDP against the dev build): opened with Cmd+P; mtgnts fuzzy-matched meeting-notes-2026-07-02.md with the subsequence characters emphasized; pagefind produced a name hit plus a content hit in indexing-strategies.md at line 3, correctly deduped; always-on recursive produced a content-only hit; Enter navigated in every case. Three consecutive open → type → Enter cycles verified after the focus fix. Ghost-click regression verified: with the palette closed, a real click at the editor centre hits the editor, not a result row. Checked in both light and dark themes.

Not in this slice

  • Web. apps/www is untouched. Its content is already client-side, so it's a small addition behind the same injected searchContents prop.
  • Jump to the matching line. Selecting a content result opens the file at its top. Mapping a source offset to a ProseMirror position is a real problem and would roughly double this change; it becomes near-free once source mode (Add source mode (raw markdown editing) to the editor #142) lands.

Reviewer notes

The two base-ui bugs above have no regression test — they're behavioral and only reproduce in a real Electron renderer. A future refactor of the palette can silently reintroduce them. Happy to add a harness if you'd like one, though it needs a running app rather than jsdom.

How it works in the app

Screenshare.-.2026-07-09.9_02_20.PM.mp4

zcuric and others added 2 commits July 10, 2026 11:27
Records the architecture decision the issue asks for: naive on-demand
search on both surfaces, no dedicated content index, and Pagefind
rejected as a build-time indexer that does not fit markdown files that
change on every keystroke.

Desktop reads content from the paths the sidebar snapshot already lists,
so search inherits ADR-0008's staleness contract instead of introducing
a second view of the folder or a recursive watcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cmd+P (or File > Go to File...) opens a cmdk palette that fuzzy-matches
file names and paths synchronously, and searches file content in the
Electron main process behind a 150ms debounce. Results are grouped, a
file matching both ways appears once, and selecting one navigates via
the existing loadPath chokepoint.

Content search takes its candidate paths from the sidebar snapshot the
renderer already holds rather than re-walking the folder, so search sees
exactly what the sidebar sees (ADR-0008). Main still asserts granted
scope on every path. A superseded search is abandoned by comparing
request ids between files, since an AbortSignal cannot cross IPC.

Measured at ~45ms for a full scan of 2,000 notes / 16 MiB; a common
needle short-circuits on the 50-file cap.

Two base-ui traps, both found by driving the running app:

- Dialog.Popup stays mounted after close and keeps hit-testing at
  opacity 0, so a click in the middle of the editor opened whichever
  file sat under the cursor. Fixed with data-[closed]:pointer-events-none
  on the popup and backdrop.
- autoFocus fires only on mount, so a second Cmd+P left the caret in the
  editor and the palette swallowed every keystroke. Fixed with
  initialFocus on Dialog.Popup; a manual focus() races base-ui's own
  focus management and lands non-deterministically.

Modal has both latent behaviors and is left for a separate change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

@zcuric is attempting to deploy a commit to the bholmesdev's projects Team on Vercel.

A member of the Team first needs to authorize it.

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.

Add global search across Markdown Files

1 participant