From fde38fe5c4f1f27b7595b221520bbe32524879b6 Mon Sep 17 00:00:00 2001 From: Zdravko Curic Date: Fri, 10 Jul 2026 11:27:14 +0200 Subject: [PATCH 1/3] Add product and tech specs for global search (#117) 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) --- specs/gh-117/PRODUCT.md | 77 +++++++++++++++++ specs/gh-117/TECH.md | 181 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 specs/gh-117/PRODUCT.md create mode 100644 specs/gh-117/TECH.md diff --git a/specs/gh-117/PRODUCT.md b/specs/gh-117/PRODUCT.md new file mode 100644 index 0000000..3ec5775 --- /dev/null +++ b/specs/gh-117/PRODUCT.md @@ -0,0 +1,77 @@ +# Global search across Markdown Files + +## Summary + +Global search gives desktop users one palette, opened with `CmdOrCtrl+P`, that finds Markdown Files in the open folder two ways at once: fuzzy matching on file names and paths, and literal matching on file content. Selecting a result opens that Markdown File. + +## Problem + +The sidebar helps only when the user already knows the file name and can spot it in the tree. It answers "where is `meeting-notes.md`?" but not "which note mentioned Pagefind?". Today the second question sends users to `rg` in a terminal, or to a different app entirely. + +## Goals + +1. Find a Markdown File by name or path without knowing its exact spelling or location. +2. Find a Markdown File by a phrase inside it, with enough surrounding text to recognize the right hit. +3. Reach both from a single entry point, without the user first choosing which kind of search they want. +4. Stay responsive on a large open folder, and stay honest when results are capped. + +## Non-goals + +1. No jump-to-match. Selecting a content result opens the Markdown File at its top, not at the matching line. Mapping a source offset to a position in the rich editor is a separate problem. +2. No regular expressions, no case-sensitivity toggle, no whole-word toggle. Content matching is literal and case-insensitive. +3. No search across File Properties as structured fields. Front matter is matched only as raw text, like any other part of the file. +4. No web surface in this slice. `apps/www` is unchanged. The shared palette component is built so web can supply its own content search later. +5. No replace, and no multi-file editing. +6. No persistent search index, and no new filesystem watcher. + +## Design Context + +The entry point is a modal command palette, not a sidebar panel. Hubble already has two `cmdk`-driven command menus — the Slash Command menu and the `CmdOrCtrl+/` Format Command menu — so the interaction vocabulary (type to filter, arrow keys to move, `Enter` to run, `Escape` to dismiss) is established. Global search reuses it at app scope instead of editor scope. + +`CmdOrCtrl+F` is already the in-note Find bar and `CmdOrCtrl+K` is already link insertion. `CmdOrCtrl+P` is free, and carries the "quick open" meaning from other editors. + +Search reads the same file list the sidebar shows. Per ADR-0008, that list is an ephemeral snapshot: it respects `.gitignore` and `.ignore`, prunes `.git/`, `dist/`, and `node_modules/`, and refreshes on window focus or `File > Sync Workspace` rather than through a live watcher. Search inherits that contract exactly rather than building a parallel view of the folder. A file the sidebar cannot see is a file search cannot find, and both become correct again at the same moment. + +## Behavior + +### Opening and dismissing + +1. With a Workspace Folder or Plain Folder open, `CmdOrCtrl+P` opens the search palette, and `File > Go to File…` does the same. +2. When no folder is open — including when a Loose File is the current document — `CmdOrCtrl+P` does nothing and the menu item is disabled. +3. The palette opens with an empty query and the text input focused. +4. `Escape` or a click outside dismisses it and returns focus to the previously focused element. +5. Dismissing discards the query. Reopening starts empty. + +### Searching + +6. With an empty query, the palette lists recently modified Markdown Files from the current snapshot, most recent first, so the palette is useful before the user types anything. +7. Typing one or more characters matches file names and paths immediately, with no perceptible delay. A match on the file name ranks above a match that only appears elsewhere in the path. +8. Typing three or more characters additionally searches file content. Content results appear shortly after name results, and the palette does not block, reorder, or clear the name results while content search is running. +9. Name and path matching is fuzzy: the typed characters must appear in order but need not be adjacent, and separators such as spaces, hyphens, and underscores are ignored. Content matching is literal and case-insensitive. +10. Results are grouped, with name and path matches shown before content matches. A Markdown File that matches both ways appears once, under name matches. +11. Each name result shows the file name and its folder path relative to the open folder, with the matched characters emphasized. +12. Each content result shows the file name, the line number of the first match, and a one-line excerpt with the matched text emphasized in context. When a file matches on several lines, at most three excerpts are shown for that file. +13. Content search covers Markdown Files only. Name and path search also covers HTML Apps, since they are openable documents in the sidebar. +14. While content search is running, the palette shows a quiet in-progress indication. Changing the query abandons the in-flight search rather than queueing another. + +### Results and limits + +15. Selecting a result with `Enter` or a click opens that Markdown File in the editor and dismisses the palette. This is the same navigation the sidebar performs, and it participates in document history the same way. +16. Arrow keys move the highlight across all groups as one list. The first result is highlighted on open. +17. When a query matches nothing, the palette says so plainly and offers no result rows. +18. Content search stops after 50 matching files. When it stops early, the palette states that results were capped rather than presenting a partial list as complete. +19. Files larger than 2 MiB are skipped by content search. This is not surfaced per-file; it is documented behavior. +20. A file that has changed on disk since the last snapshot refresh may be missing from results, or may show a stale excerpt. This resolves on the next focus refresh or `File > Sync Workspace`, exactly as it does for the sidebar. +21. Search never reaches outside the open folder, and never reads a path the sidebar snapshot does not already list. + +## UX Validation + +1. Open a folder containing at least a few dozen Markdown Files across nested folders. +2. Press `CmdOrCtrl+P`. Confirm the palette opens focused and empty, listing recently modified files. +3. Type a partial, misspelled-by-omission file name (e.g. `mtgnts` for `meeting-notes.md`). Confirm the file appears, with matched characters emphasized. +4. Type a phrase you know appears inside exactly one note and nowhere in any file name. Confirm a content result appears with the right line number and a readable excerpt. +5. Press `Enter` on that content result. Confirm the palette closes and the correct Markdown File opens. +6. Type a query matching many files. Confirm results appear promptly and, if capped, that the palette says results were capped. +7. Type quickly and continuously. Confirm no stale result set ever replaces a newer one, and the palette never flickers back to older matches. +8. Add a `.gitignore` entry covering one note, trigger `File > Sync Workspace`, and confirm the note leaves both the sidebar and search. +9. Close the folder, open a Loose File, and confirm `CmdOrCtrl+P` does nothing and the menu item is disabled. diff --git a/specs/gh-117/TECH.md b/specs/gh-117/TECH.md new file mode 100644 index 0000000..1f630a8 --- /dev/null +++ b/specs/gh-117/TECH.md @@ -0,0 +1,181 @@ +# Global search across Markdown Files — technical plan + +## Context + +Issue: https://github.com/bholmesdev/hubble.md/issues/117 + +Product spec: `specs/gh-117/PRODUCT.md` + +Current commit researched: `28cdd402dcb7e25adac184686c80eae4a1e19a5b` + +Desktop file discovery already exists: `collectDocumentFiles` (`apps/desktop/electron/main.ts`) walks the open folder, applies `.gitignore` / `.ignore` via `rulesForDir`, prunes `ignoredWorkspaceDirs` (`.git`, `dist`, `node_modules`) and hidden sidebar folders, and returns a flat `DirectoryListing` of `{ path, modified_at }`. It is exposed as `desktop:list-directory`, consumed by `refreshFiles` (`apps/desktop/src/store/actions.ts`) and parked in `workspaceStore.files`. Every path-taking IPC handler gates on `assertGranted` / `assertGrantedRoot` against the granted-scope allowlist. File reads go through `desktop:read-file-text`. `hasDocumentExtension` (`apps/desktop/src/lib/filePath.ts`) is markdown-or-html; `hasMarkdownExtension` is markdown alone. + +`loadPath` (`apps/desktop/src/store/actions.ts`) is the single chokepoint for "navigate the editor to this path", shared by the sidebar, wiki links, and HTML App `files.open`. + +Two `cmdk` command menus exist in `packages/ui/src/editor/`: `SlashCommandMenu.tsx` and `FormatCommandMenu.tsx`. Both set `shouldFilter={false}` and filter through a hand-rolled `commandScore` / `isSubsequence` / `normalize` trio (`SlashCommandMenu.tsx`) that ranks exact `1` > prefix `0.9` > substring `0.75` > subsequence `0.45`. `FormatCommandMenu` is the closer precedent: a real `Command.Input` with its own `query` state, opened from a `keymatch`-based global `keydown` listener. App-level shortcuts live in one handler in `apps/desktop/src/App.tsx`; `CmdOrCtrl+P` is unbound. `Modal` (`packages/ui/src/primitives/modal.tsx`) wraps `@base-ui/react/dialog`. + +## Decision: naive search, no dedicated index + +The issue asks us to choose between naive on-demand search and a dedicated content index, and to weigh Pagefind. We choose **naive on-demand search on both surfaces**, and reject Pagefind. + +**Pagefind is the wrong shape.** It is a build-time indexer: a CLI crawls built HTML output and emits a WASM runtime plus static index fragments. Hubble has no build step over user content, and that content mutates on every keystroke. The parts of Pagefind worth learning from are its ranking behavior and its excerpt-with-context presentation, not its architecture. + +**Web would not benefit from an index.** The Convex `files` table already stores `content: v.string()` inline (`packages/sync-backend/convex/schema.ts`), and `getFilesByWorkspace` returns full rows. `loadPath` in `apps/www/src/store/actions.ts` does not fetch one file — it calls `backend.getFiles(workspaceId)` and `.find()`s the path, because every note body is already resident in client memory. Adding a Convex `.searchIndex()` would build a server-side index to search data the client already holds. When web lands, its content search is an in-memory scan. + +**Desktop's cost is bounded, and an index would fight ADR-0008.** A persistent content index needs invalidation, and the only cheap invalidation signal is a recursive watcher — precisely what ADR-0008 rules out. Instead, content search reads the paths the sidebar snapshot already lists, on demand, debounced and abortable. Search then inherits ADR-0008's staleness contract for free: it sees exactly what the sidebar sees, and both become correct at the same moment. No second view of the folder, no cache to invalidate, no watcher. + +The load this accepts is a full read of the candidate set per settled keystroke, concurrency-limited and abortable, with the OS page cache absorbing repeats. Measured on 2,000 markdown files totalling 16 MiB (concurrency 8, warm cache, Apple silicon): + +| 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, and it costs ~45 ms — comfortably inside the 150 ms debounce, so a settled keystroke never queues behind the previous search. A common needle is *faster*, not slower, because the `MAX_RESULT_FILES` cap short-circuits the walk almost immediately. If a larger corpus ever misses this budget, the escalation is a main-process content cache keyed by `path → { mtimeMs, content }` and validated by `stat` — strictly cheaper than a real index, and still no watcher. That is a deferred optimization, listed below, not part of this slice. + +## Approach + +### Renderer sends the paths; main never walks + +The renderer already holds the snapshot in `workspaceStore.files`. Content search passes those paths to main rather than asking main to re-walk the folder. This is what makes "search sees exactly what the sidebar sees" true by construction instead of by convention, and it keeps `collectDocumentFiles` as the single place that decides what is visible. + +Main still calls `assertGranted` on every path it is handed. A compromised renderer gains nothing. + +### IPC: `desktop:search-file-contents` + +Add to `DesktopApi` (`apps/desktop/src/desktopApi/types.ts`), preload (`electron/preload.ts`), and `registerIpc` (`electron/main.ts`): + +```ts +export type SearchContentMatch = { + line: number; // 1-indexed + excerpt: string; // trimmed line, window around the first match + matchStart: number; // offset into excerpt + matchEnd: number; +}; + +export type SearchFileResult = { + path: string; + matches: SearchContentMatch[]; // capped at MAX_MATCHES_PER_FILE +}; + +export type SearchFileContentsOutput = { + requestId: number; + results: SearchFileResult[]; + truncated: boolean; // hit MAX_RESULT_FILES +}; + +searchFileContents(input: { + requestId: number; + paths: string[]; + query: string; +}): Promise; +``` + +Main-process implementation: + +- Keep a module-level `latestSearchRequestId`. On entry, record `requestId`; between files, bail out if it is no longer the latest. This is real cancellation — an AbortSignal cannot cross IPC. +- Filter `paths` to `hasMarkdownExtension`, then `assertGranted` each. +- `stat` each candidate; skip when `size > MAX_FILE_BYTES`. +- Read with a concurrency pool of `SEARCH_CONCURRENCY`. +- Match case-insensitively on a literal needle. No regex: it avoids ReDoS on user input and avoids the question of which dialect we promise. +- Per file, collect up to `MAX_MATCHES_PER_FILE` matches, each with line number and an excerpt windowed to `EXCERPT_CONTEXT_CHARS` either side of the match. +- Stop after `MAX_RESULT_FILES` files have matched, and set `truncated: true`. + +Constants, in one place in main: `MAX_FILE_BYTES = 2 * 1024 * 1024`, `MAX_RESULT_FILES = 50`, `MAX_MATCHES_PER_FILE = 3`, `SEARCH_CONCURRENCY = 8`, `EXCERPT_CONTEXT_CHARS = 40`. + +Errors on individual files (deleted between snapshot and read, permission denied) are skipped, not thrown. A stale snapshot must not fail the whole search. + +### Fuzzy path scoring + +New `packages/ui/src/lib/fuzzy.ts`, unit-tested: + +- `scoreText(query, haystack): number` — the existing exact/prefix/substring/subsequence ladder, extracted in behavior but written fresh here. +- `matchRanges(query, haystack): Array<[start, end]>` — character ranges to emphasize in the UI. The existing menus do not need this; the palette does. +- `scorePath(query, relativePath): number` — `max(scoreText(query, basename), 0.8 * scoreText(query, relativePath))`, so a basename hit outranks a hit that only exists in a parent folder name. + +`normalize` strips whitespace, `-`, and `_`, matching the existing menus. It leaves `/` intact so path separators stay meaningful. + +Do **not** refactor `SlashCommandMenu` and `FormatCommandMenu` onto this module in this slice. Their scorer is entangled with command keywords and aliases, and changing their ranking is an unrelated regression risk. Deduplicating them is a follow-up. + +### Shared palette component + +New `packages/ui/src/components/GlobalSearchPalette.tsx`, presentational, with content search injected: + +```ts +type Props = { + open: boolean; + onOpenChange(open: boolean): void; + files: Array<{ path: string; modified_at: number }>; // current snapshot + rootPath: string; // to render relative paths + onSelectFile(path: string): void; + searchContents(query: string): Promise<{ + results: SearchFileResult[]; + truncated: boolean; + }>; +}; +``` + +Name and path matching runs synchronously in the component over `files`, so it lands on the first keystroke. `searchContents` is debounced by `SEARCH_DEBOUNCE_MS = 150` and gated on `query.length >= MIN_CONTENT_QUERY_LENGTH = 3`. Content results land in separate state, so they never clear or reorder name results while in flight; a stale resolve is discarded by comparing against the current query. + +Files that already matched by name are filtered out of the content group, satisfying "appears once, under name matches." + +Rendering follows `FormatCommandMenu`: `Command` with `shouldFilter={false}`, a real `Command.Input`, `Command.List`, two `Command.Group`s. Arrow-key traversal and `Enter` come from `cmdk` for free, which is why the two groups render as one navigable list. + +The palette does **not** reuse `Modal`. `Modal` is `max-w-md`, vertically centered, and renders a title/close header — a palette wants to be wider, anchored toward the top, and chromeless. It composes `@base-ui/react/dialog` directly, matching `Modal`'s backdrop and animation classes. Spacing uses logical properties throughout. + +### Desktop wiring + +- `apps/desktop/src/App.tsx`: add `CmdOrCtrl+P` to the existing `keymatch` chain, guarded by `workspaceStore.get().workspacePath` the same way `CmdOrCtrl+Shift+O` is. Open-only, never toggle: the File menu accelerator fires alongside the renderer keydown, exactly as it already does for `CmdOrCtrl+Shift+O`, and only an idempotent open survives that. Render `` with `files` from `workspaceStore`, `onSelectFile` → `loadPath`, and `searchContents` → a thin wrapper over `desktopApi.searchFileContents` that supplies a monotonically increasing `requestId` and the current snapshot's paths. +- `electron/main.ts` `buildMenu`: add `Go to File…` to the File submenu with accelerator `CmdOrCtrl+P`, enabled from the existing `MenuState.hasWorkspace`. New IPC `onMenuGoToFile`, following `onMenuShowWorkspaceSwitcher`. +- Because `loadPath` is the navigation chokepoint, search results participate in document history (gh-111) with no extra work. + +## What does not change + +- No new watcher, and no change to `collectDocumentFiles` or the snapshot refresh triggers. +- No Convex schema change, no `.searchIndex()`, no `SyncBackend` method. `apps/www` is untouched. +- `SlashCommandMenu` and `FormatCommandMenu` keep their own scorer. +- `Modal` is unchanged. +- No change to `loadPath`'s signature or behavior. + +## E2E test plan + +Desktop (running app; use the `test-desktop-app` skill when automating): + +1. Open a folder with nested Markdown Files. `CmdOrCtrl+P` opens the palette, focused and empty, showing recently modified files. `Escape` closes it and restores focus. +2. Type a subsequence of a file name with characters omitted; confirm the file ranks and matched characters are emphasized. Confirm a basename match outranks a parent-folder-only match. +3. Type a phrase present in exactly one note's body and in no file name; confirm one content result with the correct line number and a readable excerpt. `Enter` opens that file. +4. Type continuously and fast; confirm no older result set ever replaces a newer one, and name results never blank out while content search runs. +5. Create a folder with more than 50 files containing a common phrase; confirm the capped-results message appears. +6. Add a note to `.gitignore`, run `File > Sync Workspace`, and confirm the note disappears from both sidebar and search. +7. With no folder open (and with a Loose File open), confirm `CmdOrCtrl+P` does nothing and `Go to File…` is disabled. +8. Delete a file on disk without refreshing, then search for its content; confirm the search completes without error and simply omits it. + +Unit: + +- `packages/ui/src/lib/fuzzy.test.ts`: the score ladder, separator normalization, `scorePath` basename preference, `matchRanges` offsets including the subsequence case. +- Main-process matcher, extracted as a pure function over `(content, query)`: line numbers, excerpt windowing at line start / line end / mid-line, per-file match cap, case-insensitivity. + +Commands: + +- Iteration: `pnpm check` +- Final: `pnpm build:desktop` + +## Risks + +- **Content search is slow on a very large folder.** Mitigated by the debounce, the concurrency pool, `MAX_FILE_BYTES`, `MAX_RESULT_FILES`, and real `requestId` cancellation. Measured at ~45 ms worst case over 2,000 notes (see above). Re-measure if the corpus grows an order of magnitude; if it misses, the content cache below is the next move, not an index. +- **Stale excerpts.** Search reads from disk while the path list comes from a snapshot that may lag. The excerpt is therefore current and the path list is not. This is ADR-0008's contract, stated in the product spec rather than papered over. +- **Blocking the main process.** Reads are async and pooled, but a pathological folder could still starve the event loop. If it does, move the matcher to a `utilityProcess` behind the same IPC signature. +- **base-ui keeps `Dialog.Popup` mounted after close**, and that single fact caused two distinct bugs, both found by driving the running app rather than by tests. Verified that `Modal` shares the mounted-when-closed behavior, so both traps are latent in `packages/ui/src/primitives/modal.tsx` too and should be addressed separately. + - *Ghost clicks.* At `opacity: 0` the popup still hit-tests, so a click in the middle of the editor landed on an invisible result row and opened whichever file sat under the cursor. Fixed with `data-[closed]:pointer-events-none` on both the popup and the backdrop. + - *Dead second open.* `autoFocus` on the input fires only on mount, so the second `CmdOrCtrl+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. A manual `focus()` in an effect is *not* sufficient — it races base-ui's own focus management and lands the focus non-deterministically. +- **`CmdOrCtrl+P` on web later.** It is browser print. When web lands, either bind `CmdOrCtrl+K` there or accept `preventDefault`. Out of scope now, but do not let a shared component hard-code the chord. + +## Follow-ups + +- Web surface: implement `searchContents` over the already-resident `getFiles` content and mount the same palette in `apps/www`. +- Jump to the matching line when opening a content result. Needs a source-offset to ProseMirror-position mapping; genuinely hard in the rich editor, and near-free in source mode (gh-142). +- Main-process content cache keyed by `path → { mtimeMs, content }`, validated by `stat` before read. +- Send the path list once per palette session instead of per query, if the IPC payload ever shows up in a profile. +- Deduplicate the fuzzy scorer across `SlashCommandMenu`, `FormatCommandMenu`, and `fuzzy.ts`. +- Search File Properties as structured fields rather than raw front matter text. From f445c32e69476539019a4a37e142f4c655193917 Mon Sep 17 00:00:00 2001 From: Zdravko Curic Date: Fri, 10 Jul 2026 11:27:28 +0200 Subject: [PATCH 2/3] Add global search palette to desktop (#117) 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) --- CHANGELOG.md | 2 + apps/desktop/electron/main.ts | 78 ++++ apps/desktop/electron/preload.ts | 3 + apps/desktop/src/App.tsx | 45 +- apps/desktop/src/desktopApi/types.ts | 33 ++ apps/desktop/src/lib/searchContent.test.ts | 87 ++++ apps/desktop/src/lib/searchContent.ts | 53 +++ .../ui/src/components/GlobalSearchPalette.tsx | 394 ++++++++++++++++++ packages/ui/src/index.ts | 8 + packages/ui/src/lib/fuzzy.test.ts | 70 ++++ packages/ui/src/lib/fuzzy.ts | 110 +++++ 11 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/searchContent.test.ts create mode 100644 apps/desktop/src/lib/searchContent.ts create mode 100644 packages/ui/src/components/GlobalSearchPalette.tsx create mode 100644 packages/ui/src/lib/fuzzy.test.ts create mode 100644 packages/ui/src/lib/fuzzy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd65eef..96163cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Global search: press Cmd+P (or File → Go to File…) to find notes by name, by path, or by a phrase inside them. Results show a matching excerpt, and selecting one opens the note. Thanks [@zcuric](https://github.com/zcuric)! [#159](https://github.com/bholmesdev/hubble.md/pull/159) + ### Changed ### Fixed diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5f6c386..fca6d29 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -26,6 +26,7 @@ import type { DesktopUpdateState, DirectoryListing, MenuState, + SearchFileResult, WorkspaceConfig, } from "../src/desktopApi/types"; import { @@ -35,6 +36,13 @@ import { markdownAssetFolderPath, withMarkdownExtension, } from "../src/lib/filePath"; +import { + findMatchesInContent, + SEARCH_CONCURRENCY, + SEARCH_MAX_FILE_BYTES, + SEARCH_MAX_RESULT_FILES, + SEARCH_MIN_QUERY_LENGTH, +} from "../src/lib/searchContent"; import { setupTerminalIpc } from "./terminal"; import { loadZoomFactor, @@ -154,6 +162,9 @@ const watchers = new Map(); const grantedFiles = new Set(); const grantedRoots = new Set(); let grantsLoaded = false; +// An AbortSignal cannot cross IPC, so a superseded search is abandoned by +// comparing its id against the newest one between files. +let latestSearchRequestId = 0; const ignoreConfigFiles = [".gitignore", ".ignore"]; const ignoredWorkspaceDirs = new Set([".git", "dist", "node_modules"]); @@ -778,6 +789,14 @@ function buildMenu() { click: () => sendToRenderer("desktop:menu-show-workspace-switcher"), }, { type: "separator" }, + { + id: "go-to-file", + label: "Go to File...", + accelerator: "CmdOrCtrl+P", + enabled: menuState.hasWorkspace, + click: () => sendToRenderer("desktop:menu-go-to-file"), + }, + { type: "separator" }, { id: "sync-workspace", label: "Sync Workspace", @@ -1278,6 +1297,65 @@ function registerIpc() { }, ); + ipcMain.handle( + "desktop:search-file-contents", + async (_event, { requestId, paths, query }) => { + latestSearchRequestId = requestId; + const needle = String(query ?? "").trim(); + const empty = { requestId, results: [], truncated: false }; + if (needle.length < SEARCH_MIN_QUERY_LENGTH) return empty; + + // The renderer hands us the sidebar snapshot's paths, so search sees + // exactly what the sidebar sees (ADR-0008) and main never re-walks. + const candidates = (paths as string[]).filter(hasMarkdownExtension); + const results: SearchFileResult[] = []; + const isStale = () => requestId !== latestSearchRequestId; + let cursor = 0; + let capped = false; + + async function worker() { + while (true) { + if (isStale()) return; + if (results.length >= SEARCH_MAX_RESULT_FILES) { + capped = true; + return; + } + const index = cursor; + cursor += 1; + if (index >= candidates.length) return; + + const candidate = candidates[index]; + try { + const resolved = assertGranted(candidate); + const stat = await fs.stat(resolved); + if (!stat.isFile() || stat.size > SEARCH_MAX_FILE_BYTES) continue; + const content = await fs.readFile(resolved, "utf8"); + const matches = findMatchesInContent(content, needle); + if (matches.length > 0) results.push({ path: candidate, matches }); + } catch {} + } + } + + await Promise.all( + Array.from( + { length: Math.min(SEARCH_CONCURRENCY, candidates.length) }, + worker, + ), + ); + if (isStale()) return empty; + + return { + requestId, + // The worker pool finishes out of order; sort so equal-ranked results + // do not jitter between keystrokes. + results: results + .slice(0, SEARCH_MAX_RESULT_FILES) + .sort((a, b) => a.path.localeCompare(b.path)), + truncated: capped && cursor < candidates.length, + }; + }, + ); + ipcMain.handle( "desktop:write-file-text", async (_event, { path: filePath, bytes }) => { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 29df66c..c381d4a 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -30,6 +30,8 @@ const desktopApi = { }), readFileText: (path) => ipcRenderer.invoke("desktop:read-file-text", { path }), + searchFileContents: (input) => + ipcRenderer.invoke("desktop:search-file-contents", input), detectHubbleSkills: (workspacePath) => ipcRenderer.invoke("desktop:detect-hubble-skills", { workspacePath }), writeFileText: (path, content) => { @@ -105,6 +107,7 @@ const desktopApi = { subscribe("desktop:menu-copy-as-markdown", callback), onMenuShowWorkspaceSwitcher: (callback) => subscribe("desktop:menu-show-workspace-switcher", callback), + onMenuGoToFile: (callback) => subscribe("desktop:menu-go-to-file", callback), onMenuSyncWorkspace: (callback) => subscribe("desktop:menu-sync-workspace", callback), onMenuToggleTerminal: (callback) => diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e633abe..de85513 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,13 +3,15 @@ import { Button, classifyHref, EditorView, + GlobalSearchPalette, Input, MarkdownSourceEditor, + type PaletteFile, type WikiTarget, } from "@hubble.md/ui"; import { useStoreValue } from "@simplestack/store/react"; import { keymatch } from "keymatch"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import MingcutePencilLine from "~icons/mingcute/pencil-line"; import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState"; @@ -100,6 +102,23 @@ async function revealPath(path: string | null) { } } +let nextSearchRequestId = 0; + +/** + * Content search reads the sidebar snapshot's paths rather than asking main to + * re-crawl, so search and the sidebar always agree on what exists (ADR-0008). + */ +async function searchFileContents(query: string) { + nextSearchRequestId += 1; + const { files } = workspaceStore.get(); + const { results, truncated } = await desktopApi.searchFileContents({ + requestId: nextSearchRequestId, + paths: files.map((file) => file.path), + query, + }); + return { results, truncated }; +} + function App() { const state = useStoreValue(viewerStore); const workspacePath = useStoreValue(workspacePathStore); @@ -119,6 +138,17 @@ function App() { null, ); const [dismissedVersion, setDismissedVersion] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const workspaceFiles = useStoreValue(workspaceStore).files; + const paletteFiles: PaletteFile[] = useMemo( + () => + workspaceFiles.map((file) => ({ + path: file.path, + relativePath: relativeWorkspacePath(file.path, workspacePath ?? null), + modifiedAt: file.modified_at, + })), + [workspaceFiles, workspacePath], + ); const readyVersion = updateState?.status === "ready" @@ -242,6 +272,11 @@ function App() { if (!workspaceStore.get().workspacePath) return; event.preventDefault(); setWorkspaceSwitcherOpen(true); + } else if (keymatch(event, "CmdOrCtrl+P")) { + if (!workspaceStore.get().workspacePath) return; + event.preventDefault(); + // The File menu accelerator fires too, but opening is idempotent. + setSearchOpen(true); } else if (keymatch(event, "CmdOrCtrl+Shift+N")) { event.preventDefault(); await openWorkspaceWithSidebar(); @@ -315,6 +350,7 @@ function App() { desktopApi.onMenuShowWorkspaceSwitcher(() => setWorkspaceSwitcherOpen(true), ), + desktopApi.onMenuGoToFile(() => setSearchOpen(true)), desktopApi.onMenuSyncWorkspace(() => void refreshFiles()), desktopApi.onMenuToggleTerminal(() => toggleTerminal()), desktopApi.onMenuGoBack(() => void goBack()), @@ -453,6 +489,13 @@ function App() { + void loadPath(path)} + searchContents={searchFileContents} + /> {updateState ? ( diff --git a/apps/desktop/src/desktopApi/types.ts b/apps/desktop/src/desktopApi/types.ts index 22ce1b9..dfec6a9 100644 --- a/apps/desktop/src/desktopApi/types.ts +++ b/apps/desktop/src/desktopApi/types.ts @@ -17,6 +17,35 @@ export type HtmlAppFileEntry = { size: number; }; +export type SearchContentMatch = { + /** 1-indexed line number within the file. */ + line: number; + /** Trimmed line, windowed around the match, with ellipses when clipped. */ + excerpt: string; + matchStart: number; + matchEnd: number; +}; + +export type SearchFileResult = { + path: string; + matches: SearchContentMatch[]; +}; + +export type SearchFileContentsInput = { + /** Monotonic per-renderer id. Main abandons a search once it is superseded. */ + requestId: number; + /** Candidate paths, taken from the sidebar snapshot. Main never re-walks. */ + paths: string[]; + query: string; +}; + +export type SearchFileContentsOutput = { + requestId: number; + results: SearchFileResult[]; + /** True when the file cap was hit before every candidate was scanned. */ + truncated: boolean; +}; + export type PersistPastedImageInput = { filePath: string; bytes: number[]; @@ -90,6 +119,9 @@ export type DesktopApi = { config: WorkspaceConfig, ): Promise; readFileText(path: string): Promise; + searchFileContents( + input: SearchFileContentsInput, + ): Promise; detectHubbleSkills(workspacePath: string): Promise; writeFileText(path: string, content: string): Promise; createFolder(path: string): Promise; @@ -136,6 +168,7 @@ export type DesktopApi = { onMenuOpenSettings(callback: () => void): Unsubscribe; onMenuCopyAsMarkdown(callback: () => void): Unsubscribe; onMenuShowWorkspaceSwitcher(callback: () => void): Unsubscribe; + onMenuGoToFile(callback: () => void): Unsubscribe; onMenuSyncWorkspace(callback: () => void): Unsubscribe; onMenuToggleTerminal(callback: () => void): Unsubscribe; onMenuGoBack(callback: () => void): Unsubscribe; diff --git a/apps/desktop/src/lib/searchContent.test.ts b/apps/desktop/src/lib/searchContent.test.ts new file mode 100644 index 0000000..c8a844a --- /dev/null +++ b/apps/desktop/src/lib/searchContent.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { findMatchesInContent } from "./searchContent"; + +/** The excerpt slice that the UI will emphasize, for offset assertions. */ +function highlighted(excerpt: string, start: number, end: number) { + return excerpt.slice(start, end); +} + +describe("findMatchesInContent", () => { + it("returns 1-indexed line numbers", () => { + const matches = findMatchesInContent("alpha\nbeta\ngamma", "beta"); + expect(matches).toHaveLength(1); + expect(matches[0].line).toBe(2); + }); + + it("matches case-insensitively", () => { + const matches = findMatchesInContent("The Pagefind Reference", "pagefind"); + expect(matches).toHaveLength(1); + expect(highlighted(matches[0].excerpt, ...offsets(matches[0]))).toBe( + "Pagefind", + ); + }); + + it("keeps the whole line when it fits in the context window", () => { + const matches = findMatchesInContent("hello world", "world"); + expect(matches[0].excerpt).toBe("hello world"); + expect(highlighted(matches[0].excerpt, ...offsets(matches[0]))).toBe( + "world", + ); + }); + + it("windows long lines and marks the clipped sides with ellipses", () => { + const line = `${"a".repeat(100)}needle${"b".repeat(100)}`; + const [match] = findMatchesInContent(line, "needle", 3, 10); + expect(match.excerpt).toBe(`…${"a".repeat(10)}needle${"b".repeat(10)}…`); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); + }); + + it("does not add a leading ellipsis when the window reaches the line start", () => { + const [match] = findMatchesInContent( + `needle${"b".repeat(100)}`, + "needle", + 3, + 10, + ); + expect(match.excerpt.startsWith("needle")).toBe(true); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); + }); + + it("keeps offsets correct when leading indentation is trimmed", () => { + const [match] = findMatchesInContent( + "\t\t indented needle here", + "needle", + ); + expect(match.excerpt).toBe("indented needle here"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); + }); + + it("keeps offsets correct for a match at the very end of a line", () => { + const [match] = findMatchesInContent("trailing needle ", "needle"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); + }); + + it("tolerates CRLF line endings", () => { + const [match] = findMatchesInContent("one\r\nneedle here\r\n", "needle"); + expect(match.line).toBe(2); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); + }); + + it("reports at most one match per line, and caps matches per file", () => { + const content = "needle needle\nneedle\nneedle\nneedle"; + expect(findMatchesInContent(content, "needle", 3)).toHaveLength(3); + }); + + it("returns nothing for a blank query", () => { + expect(findMatchesInContent("anything", " ")).toEqual([]); + expect(findMatchesInContent("anything", "")).toEqual([]); + }); + + it("returns nothing when the needle is absent", () => { + expect(findMatchesInContent("alpha\nbeta", "gamma")).toEqual([]); + }); +}); + +function offsets(match: { matchStart: number; matchEnd: number }) { + return [match.matchStart, match.matchEnd] as const; +} diff --git a/apps/desktop/src/lib/searchContent.ts b/apps/desktop/src/lib/searchContent.ts new file mode 100644 index 0000000..10057f4 --- /dev/null +++ b/apps/desktop/src/lib/searchContent.ts @@ -0,0 +1,53 @@ +import type { SearchContentMatch } from "../desktopApi/types"; + +export const SEARCH_MIN_QUERY_LENGTH = 3; +export const SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +export const SEARCH_MAX_RESULT_FILES = 50; +export const SEARCH_MAX_MATCHES_PER_FILE = 3; +export const SEARCH_CONCURRENCY = 8; +export const SEARCH_EXCERPT_CONTEXT_CHARS = 40; + +/** + * Literal, case-insensitive line matching with a windowed excerpt. + * + * Deliberately not a regex: user input would need escaping anyway, and a regex + * dialect is a promise we do not want to make for an MVP search box. + */ +export function findMatchesInContent( + content: string, + query: string, + maxMatches = SEARCH_MAX_MATCHES_PER_FILE, + contextChars = SEARCH_EXCERPT_CONTEXT_CHARS, +): SearchContentMatch[] { + const needle = query.toLowerCase(); + if (needle.trim() === "") return []; + + const matches: SearchContentMatch[] = []; + const lines = content.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + if (matches.length >= maxMatches) break; + const line = lines[index].replace(/\r$/, ""); + const at = line.toLowerCase().indexOf(needle); + if (at === -1) continue; + + const windowStart = Math.max(0, at - contextChars); + const windowEnd = Math.min(line.length, at + needle.length + contextChars); + const windowed = line.slice(windowStart, windowEnd); + + // Trim only outside the match: leading whitespace sits before the match + // and trailing whitespace after it, so neither trim can eat into it. + const leading = windowed.length - windowed.trimStart().length; + const trimmed = windowed.trim(); + const prefix = windowStart > 0 ? "…" : ""; + const suffix = windowEnd < line.length ? "…" : ""; + + const matchStart = at - windowStart - leading + prefix.length; + matches.push({ + line: index + 1, + excerpt: `${prefix}${trimmed}${suffix}`, + matchStart, + matchEnd: matchStart + needle.length, + }); + } + return matches; +} diff --git a/packages/ui/src/components/GlobalSearchPalette.tsx b/packages/ui/src/components/GlobalSearchPalette.tsx new file mode 100644 index 0000000..8d39f3c --- /dev/null +++ b/packages/ui/src/components/GlobalSearchPalette.tsx @@ -0,0 +1,394 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { Command } from "cmdk"; +import { useEffect, useMemo, useRef, useState } from "react"; +import MingcuteCornerDownLeftLine from "~icons/mingcute/corner-down-left-line"; +import MingcuteFileLine from "~icons/mingcute/file-line"; +import MingcuteSearch2Line from "~icons/mingcute/search-2-line"; +import { type MatchRange, matchRanges, scorePath } from "../lib/fuzzy"; + +const SEARCH_DEBOUNCE_MS = 150; +const MIN_CONTENT_QUERY_LENGTH = 3; +const MAX_NAME_RESULTS = 50; +const MAX_RECENT_FILES = 20; + +export type PaletteFile = { + path: string; + relativePath: string; + modifiedAt: number; +}; + +export type PaletteContentMatch = { + line: number; + excerpt: string; + matchStart: number; + matchEnd: number; +}; + +export type PaletteFileMatches = { + path: string; + matches: PaletteContentMatch[]; +}; + +export type PaletteContentResult = { + results: PaletteFileMatches[]; + truncated: boolean; +}; + +export type GlobalSearchPaletteProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + files: PaletteFile[]; + onSelectFile: (path: string) => void; + searchContents: (query: string) => Promise; +}; + +function Highlight({ text, ranges }: { text: string; ranges: MatchRange[] }) { + if (ranges.length === 0) return <>{text}; + const parts: React.ReactNode[] = []; + let cursor = 0; + for (const [start, end] of ranges) { + if (start > cursor) parts.push(text.slice(cursor, start)); + parts.push( + + {text.slice(start, end)} + , + ); + cursor = end; + } + if (cursor < text.length) parts.push(text.slice(cursor)); + return <>{parts}; +} + +function fileName(relativePath: string) { + const slash = relativePath.lastIndexOf("/"); + return slash === -1 ? relativePath : relativePath.slice(slash + 1); +} + +function folderPath(relativePath: string) { + const slash = relativePath.lastIndexOf("/"); + return slash === -1 ? "" : relativePath.slice(0, slash); +} + +function useNameResults(files: PaletteFile[], query: string) { + return useMemo(() => { + if (query.trim() === "") { + return [...files] + .sort((a, b) => b.modifiedAt - a.modifiedAt) + .slice(0, MAX_RECENT_FILES); + } + return files + .map((file) => ({ file, score: scorePath(query, file.relativePath) })) + .filter((entry) => entry.score > 0) + .sort( + (a, b) => b.score - a.score || b.file.modifiedAt - a.file.modifiedAt, + ) + .slice(0, MAX_NAME_RESULTS) + .map((entry) => entry.file); + }, [files, query]); +} + +/** + * Content search is debounced and runs behind the name results, which match + * synchronously. A resolve for a query the user has already moved past is + * dropped rather than rendered, so results never flicker backwards. + */ +function useContentResults( + query: string, + open: boolean, + searchContents: GlobalSearchPaletteProps["searchContents"], +) { + const [result, setResult] = useState(null); + const [searching, setSearching] = useState(false); + const searchRef = useRef(searchContents); + searchRef.current = searchContents; + + useEffect(() => { + if (!open || query.trim().length < MIN_CONTENT_QUERY_LENGTH) { + setResult(null); + setSearching(false); + return; + } + + let cancelled = false; + setSearching(true); + const timer = setTimeout(() => { + searchRef + .current(query) + .then((next) => { + if (cancelled) return; + setResult(next); + setSearching(false); + }) + .catch(() => { + if (cancelled) return; + setResult(null); + setSearching(false); + }); + }, SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [open, query]); + + return { result, searching }; +} + +function GlobalSearchPalette({ + open, + onOpenChange, + files, + onSelectFile, + searchContents, +}: GlobalSearchPaletteProps) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const nameResults = useNameResults(files, query); + const { result, searching } = useContentResults(query, open, searchContents); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + + const relativeByPath = useMemo( + () => new Map(files.map((file) => [file.path, file.relativePath])), + [files], + ); + const namePaths = useMemo( + () => new Set(nameResults.map((file) => file.path)), + [nameResults], + ); + // A file that already matched by name appears once, in the name group. + const contentResults = (result?.results ?? []).filter( + (entry) => !namePaths.has(entry.path), + ); + + const select = (path: string) => { + onOpenChange(false); + onSelectFile(path); + }; + + const isEmptyQuery = query.trim() === ""; + const hasResults = nameResults.length > 0 || contentResults.length > 0; + // Content search only starts at 3 characters. Say so while the user is below + // that, instead of letting the palette look like it found nothing. + const belowContentThreshold = + !isEmptyQuery && query.trim().length < MIN_CONTENT_QUERY_LENGTH; + + return ( + + + {/* base-ui keeps the popup mounted after close. Without + `data-[closed]:pointer-events-none` the invisible result list keeps + hit-testing, and a click in the middle of the editor silently opens + whichever file sat under the cursor. */} + + {/* `--popover` sits only 0.04 lightness above `--background` in dark + mode and `--shadow-overlay` is tuned for light backgrounds, so the + ring carries the elevation the shadow cannot. + + `initialFocus` rather than `autoFocus` on the input: the popup stays + mounted after close, so `autoFocus` fires only on the first open and + a second Cmd+P would leave the caret in the editor. A manual focus() + call races base-ui's own focus management; this does not. */} + + Search files + + Find a Markdown File by name, path, or content. + + +
+ + + {searching && ( + + Searching… + + )} +
+ + + {!hasResults && !searching && ( +
+ {isEmptyQuery ? "No files yet" : "No matches"} +
+ )} + + {nameResults.length > 0 && ( + + {nameResults.map((file) => ( + select(file.path)} + className={ROW_CLASS} + > + + + ))} + + )} + + {contentResults.length > 0 && ( + + {contentResults.map((entry) => { + const relativePath = + relativeByPath.get(entry.path) ?? entry.path; + return ( + select(entry.path)} + className={ROW_CLASS} + > + +
+ {entry.matches.map((match) => ( + + + {match.line} + + + + + + ))} +
+
+ ); + })} +
+ )} + + {result?.truncated && ( +

+ Showing the first {contentResults.length} files with content + matches. Narrow your search to see more. +

+ )} +
+ +
+ + {belowContentThreshold + ? `Type ${MIN_CONTENT_QUERY_LENGTH} characters to search file content` + : "Search covers file names, paths, and content"} + + + Navigate + } + > + Open + + Close + +
+
+
+
+
+ ); +} + +const ROW_CLASS = + "flex cursor-pointer flex-col rounded-[var(--radius-row)] px-2.5 py-1.5 outline-hidden select-none data-[selected=true]:bg-accent data-[selected=true]:text-foreground"; + +function ResultGroup({ + heading, + children, +}: { + heading: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +/** File name with its folder trailing, matched characters emphasized. */ +function FileRow({ + relativePath, + query, +}: { + relativePath: string; + query: string; +}) { + const name = fileName(relativePath); + const folder = folderPath(relativePath); + const nameRanges = query ? matchRanges(query, name) : []; + // A file can rank purely on its folder ("me" finding meetings/retro.md). With + // nothing emphasized such a row reads as a false positive, so when the name + // holds no match, show where in the folder the query actually landed. + const folderRanges = + query && folder && nameRanges.length === 0 + ? matchRanges(query, folder) + : []; + + return ( + + + + + + {folder && ( + + + + )} + + ); +} + +function Legend({ + keys, + icon, + children, +}: { + keys?: string; + icon?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + {icon ?? keys} + + {children} + + ); +} + +export { GlobalSearchPalette }; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b96ee95..0beb669 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,12 @@ export { AppShellFrame } from "./components/AppShellFrame"; +export { + GlobalSearchPalette, + type GlobalSearchPaletteProps, + type PaletteContentMatch, + type PaletteContentResult, + type PaletteFile, + type PaletteFileMatches, +} from "./components/GlobalSearchPalette"; export { Sidebar, type SidebarFile, diff --git a/packages/ui/src/lib/fuzzy.test.ts b/packages/ui/src/lib/fuzzy.test.ts new file mode 100644 index 0000000..0aa8151 --- /dev/null +++ b/packages/ui/src/lib/fuzzy.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { matchRanges, scorePath, scoreText } from "./fuzzy"; + +/** The characters the palette would emphasize, for range assertions. */ +function emphasized(query: string, haystack: string) { + return matchRanges(query, haystack) + .map(([start, end]) => haystack.slice(start, end)) + .join("|"); +} + +describe("scoreText", () => { + it("ranks exact above prefix above substring above subsequence", () => { + expect(scoreText("notes", "notes")).toBe(1); + expect(scoreText("note", "notes")).toBe(0.9); + expect(scoreText("ote", "notes")).toBe(0.75); + expect(scoreText("nts", "notes")).toBe(0.45); + expect(scoreText("zzz", "notes")).toBe(0); + }); + + it("ignores case and separator characters on both sides", () => { + expect(scoreText("MeetingNotes", "meeting-notes")).toBe(1); + expect(scoreText("meeting notes", "Meeting_Notes")).toBe(1); + }); + + it("treats an empty query as a match", () => { + expect(scoreText("", "anything")).toBe(1); + }); +}); + +describe("scorePath", () => { + it("prefers a file-name hit over a parent-folder hit", () => { + expect(scorePath("report", "notes/report.md")).toBeGreaterThan( + scorePath("report", "report/notes.md"), + ); + }); + + it("still matches when the query only appears in a parent folder", () => { + expect(scorePath("archive", "archive/2024/notes.md")).toBeGreaterThan(0); + }); + + it("keeps path separators significant, so they are not matched through", () => { + // "notesmd" is a subsequence of "notes/report.md" only if `/` is skipped. + expect(scoreText("notes/report.md", "notes/report.md")).toBe(1); + }); +}); + +describe("matchRanges", () => { + it("marks a contiguous substring match", () => { + expect(emphasized("note", "meeting-notes.md")).toBe("note"); + }); + + it("marks scattered characters for a subsequence match", () => { + expect(emphasized("mtg", "meeting.md")).toBe("m|t|g"); + }); + + it("maps back through skipped separators in the haystack", () => { + expect(emphasized("meetingnotes", "meeting-notes.md")).toBe( + "meeting|notes", + ); + }); + + it("is case-insensitive but returns original-cased slices", () => { + expect(emphasized("pagefind", "Pagefind.md")).toBe("Pagefind"); + }); + + it("returns no ranges for an empty query or a non-match", () => { + expect(matchRanges("", "notes.md")).toEqual([]); + expect(matchRanges("zzz", "notes.md")).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/fuzzy.ts b/packages/ui/src/lib/fuzzy.ts new file mode 100644 index 0000000..7205360 --- /dev/null +++ b/packages/ui/src/lib/fuzzy.ts @@ -0,0 +1,110 @@ +/** + * Fuzzy scoring for file names and paths, used by the global search palette. + * + * The score ladder mirrors the command menus (exact > prefix > substring > + * subsequence) so ranking feels the same everywhere in the app. Unlike those + * menus, this module also reports *where* a match landed, so the palette can + * emphasize the matched characters. + */ + +const SEPARATOR_RE = /[\s_-]/; + +export type MatchRange = [start: number, end: number]; + +function normalize(value: string): string { + return value.toLowerCase().replace(/[\s_-]+/g, ""); +} + +/** + * Normalized text plus a map back to indices in the original string. + * + * Each entry of `map` must correspond to exactly one original character, so + * per-character lowercasing is truncated to a single code unit. Locale-exotic + * expansions (e.g. `İ` → `i̇`) would otherwise desynchronize the map. + */ +function normalizeWithMap(value: string): { text: string; map: number[] } { + let text = ""; + const map: number[] = []; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (SEPARATOR_RE.test(char)) continue; + text += char.toLowerCase()[0] ?? char; + map.push(index); + } + return { text, map }; +} + +function isSubsequence(needle: string, haystack: string): boolean { + let index = 0; + for (const char of haystack) { + if (char === needle[index]) index += 1; + if (index === needle.length) return true; + } + return needle.length === 0; +} + +/** 1 exact, 0.9 prefix, 0.75 substring, 0.45 subsequence, 0 no match. */ +export function scoreText(query: string, haystack: string): number { + const needle = normalize(query); + if (!needle) return 1; + const text = normalize(haystack); + if (text === needle) return 1; + if (text.startsWith(needle)) return 0.9; + if (text.includes(needle)) return 0.75; + if (isSubsequence(needle, text)) return 0.45; + return 0; +} + +function basename(relativePath: string): string { + const slash = relativePath.lastIndexOf("/"); + return slash === -1 ? relativePath : relativePath.slice(slash + 1); +} + +/** + * A hit on the file name outranks a hit that only exists in a parent folder + * name, so `notes/report.md` beats `report/notes.md` for the query "report". + */ +export function scorePath(query: string, relativePath: string): number { + return Math.max( + scoreText(query, basename(relativePath)), + 0.8 * scoreText(query, relativePath), + ); +} + +function toRanges(indices: number[]): MatchRange[] { + if (indices.length === 0) return []; + const ranges: MatchRange[] = []; + let start = indices[0]; + let previous = indices[0]; + for (const index of indices.slice(1)) { + if (index !== previous + 1) { + ranges.push([start, previous + 1]); + start = index; + } + previous = index; + } + ranges.push([start, previous + 1]); + return ranges; +} + +/** Character ranges of `haystack` to emphasize, in original-string indices. */ +export function matchRanges(query: string, haystack: string): MatchRange[] { + const needle = normalize(query); + if (!needle) return []; + const { text, map } = normalizeWithMap(haystack); + + const at = text.indexOf(needle); + if (at !== -1) { + return toRanges(map.slice(at, at + needle.length)); + } + + const indices: number[] = []; + let cursor = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] !== needle[cursor]) continue; + indices.push(map[index]); + cursor += 1; + if (cursor === needle.length) return toRanges(indices); + } + return []; +} From ba8a8d32d05ae82fe433767c1ba501875c5acf32 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 10:31:31 -0400 Subject: [PATCH 3/3] Polish search palette from review (#159) - fix truncated flag races at the 50-file cap - strip inline markdown from excerpts, keep snake_case searchable - drop line numbers, render excerpts in sans - chin: drop hint text, left-align keyboard legend - copy: files -> notes Co-Authored-By: Claude Fable 5 --- apps/desktop/electron/main.ts | 8 +++- apps/desktop/src/lib/searchContent.test.ts | 46 +++++++++++++++++++ apps/desktop/src/lib/searchContent.ts | 18 +++++++- .../ui/src/components/GlobalSearchPalette.tsx | 40 +++++----------- specs/gh-117/PRODUCT.md | 4 +- specs/gh-117/TECH.md | 6 +-- 6 files changed, 87 insertions(+), 35 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index fca6d29..3018e92 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1351,7 +1351,13 @@ function registerIpc() { results: results .slice(0, SEARCH_MAX_RESULT_FILES) .sort((a, b) => a.path.localeCompare(b.path)), - truncated: capped && cursor < candidates.length, + // Workers can push a few past the cap between awaits; the slice hides + // them, so they must count as truncation. `capped` alone is not + // enough of a signal in the other direction: a scan that finished + // with exactly the cap dropped nothing. + truncated: + (capped && cursor < candidates.length) || + results.length > SEARCH_MAX_RESULT_FILES, }; }, ); diff --git a/apps/desktop/src/lib/searchContent.test.ts b/apps/desktop/src/lib/searchContent.test.ts index c8a844a..c7fdd26 100644 --- a/apps/desktop/src/lib/searchContent.test.ts +++ b/apps/desktop/src/lib/searchContent.test.ts @@ -61,6 +61,52 @@ describe("findMatchesInContent", () => { expect(highlighted(match.excerpt, ...offsets(match))).toBe("needle"); }); + it("strips bold and italic markers while keeping highlight offsets", () => { + const [match] = findMatchesInContent( + "Before **bold** and _italic_ after", + "italic", + ); + expect(match.excerpt).toBe("Before bold and italic after"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("italic"); + }); + + it("strips inline code backticks", () => { + const [match] = findMatchesInContent("Run `pnpm test` now", "pnpm"); + expect(match.excerpt).toBe("Run pnpm test now"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("pnpm"); + }); + + it("replaces link syntax with link text", () => { + const [match] = findMatchesInContent( + "Read the [search guide](https://example.com/search)", + "search guide", + ); + expect(match.excerpt).toBe("Read the search guide"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("search guide"); + }); + + it("keeps intra-word underscores, so snake_case stays searchable", () => { + const [match] = findMatchesInContent( + "rename snake_case_name in _this_ file", + "snake_case_name", + ); + expect(match.excerpt).toBe("rename snake_case_name in this file"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe( + "snake_case_name", + ); + }); + + it("keeps a lone asterisk that never closes", () => { + const [match] = findMatchesInContent("compute 2 * 3 first", "2 * 3"); + expect(match.excerpt).toBe("compute 2 * 3 first"); + }); + + it("matches text that was enclosed by markdown markers", () => { + const [match] = findMatchesInContent("**bold text**", "bold text"); + expect(match.excerpt).toBe("bold text"); + expect(highlighted(match.excerpt, ...offsets(match))).toBe("bold text"); + }); + it("tolerates CRLF line endings", () => { const [match] = findMatchesInContent("one\r\nneedle here\r\n", "needle"); expect(match.line).toBe(2); diff --git a/apps/desktop/src/lib/searchContent.ts b/apps/desktop/src/lib/searchContent.ts index 10057f4..c43bffc 100644 --- a/apps/desktop/src/lib/searchContent.ts +++ b/apps/desktop/src/lib/searchContent.ts @@ -7,6 +7,22 @@ export const SEARCH_MAX_MATCHES_PER_FILE = 3; export const SEARCH_CONCURRENCY = 8; export const SEARCH_EXCERPT_CONTEXT_CHARS = 40; +function stripInlineMarkdown(line: string) { + return ( + line + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/`+([^`]*)`+/g, "$1") + // Markers strip only when they could open and close emphasis: no space + // just inside the pair, and for underscores no word character just + // outside, so snake_case and stray asterisks in prose stay searchable. + .replace(/\*\*(?!\s)([^*]+?)(?= maxMatches) break; - const line = lines[index].replace(/\r$/, ""); + const line = stripInlineMarkdown(lines[index].replace(/\r$/, "")); const at = line.toLowerCase().indexOf(needle); if (at === -1) continue; diff --git a/packages/ui/src/components/GlobalSearchPalette.tsx b/packages/ui/src/components/GlobalSearchPalette.tsx index 8d39f3c..b86dbaf 100644 --- a/packages/ui/src/components/GlobalSearchPalette.tsx +++ b/packages/ui/src/components/GlobalSearchPalette.tsx @@ -174,11 +174,6 @@ function GlobalSearchPalette({ const isEmptyQuery = query.trim() === ""; const hasResults = nameResults.length > 0 || contentResults.length > 0; - // Content search only starts at 3 characters. Say so while the user is below - // that, instead of letting the palette look like it found nothing. - const belowContentThreshold = - !isEmptyQuery && query.trim().length < MIN_CONTENT_QUERY_LENGTH; - return ( @@ -201,7 +196,7 @@ function GlobalSearchPalette({ > Search files - Find a Markdown File by name, path, or content. + Find a note by name, path, or content. {searching && ( @@ -228,12 +223,12 @@ function GlobalSearchPalette({ {!hasResults && !searching && (
- {isEmptyQuery ? "No files yet" : "No matches"} + {isEmptyQuery ? "No notes yet" : "No matches"}
)} {nameResults.length > 0 && ( - + {nameResults.map((file) => ( ( - - {match.line} - - - - + ))} @@ -289,19 +279,13 @@ function GlobalSearchPalette({ {result?.truncated && (

- Showing the first {contentResults.length} files with content - matches. Narrow your search to see more. + Some content matches aren't shown. Narrow your search.

)}
-
- - {belowContentThreshold - ? `Type ${MIN_CONTENT_QUERY_LENGTH} characters to search file content` - : "Search covers file names, paths, and content"} - - +
+ Navigate } diff --git a/specs/gh-117/PRODUCT.md b/specs/gh-117/PRODUCT.md index 3ec5775..d1160e5 100644 --- a/specs/gh-117/PRODUCT.md +++ b/specs/gh-117/PRODUCT.md @@ -50,7 +50,7 @@ Search reads the same file list the sidebar shows. Per ADR-0008, that list is an 9. Name and path matching is fuzzy: the typed characters must appear in order but need not be adjacent, and separators such as spaces, hyphens, and underscores are ignored. Content matching is literal and case-insensitive. 10. Results are grouped, with name and path matches shown before content matches. A Markdown File that matches both ways appears once, under name matches. 11. Each name result shows the file name and its folder path relative to the open folder, with the matched characters emphasized. -12. Each content result shows the file name, the line number of the first match, and a one-line excerpt with the matched text emphasized in context. When a file matches on several lines, at most three excerpts are shown for that file. +12. Each content result shows the file name and a readable one-line excerpt with inline Markdown syntax stripped and the matched text emphasized in context. The line number is returned in the payload but not rendered until jump-to-line is implemented. When a file matches on several lines, at most three excerpts are shown for that file. 13. Content search covers Markdown Files only. Name and path search also covers HTML Apps, since they are openable documents in the sidebar. 14. While content search is running, the palette shows a quiet in-progress indication. Changing the query abandons the in-flight search rather than queueing another. @@ -69,7 +69,7 @@ Search reads the same file list the sidebar shows. Per ADR-0008, that list is an 1. Open a folder containing at least a few dozen Markdown Files across nested folders. 2. Press `CmdOrCtrl+P`. Confirm the palette opens focused and empty, listing recently modified files. 3. Type a partial, misspelled-by-omission file name (e.g. `mtgnts` for `meeting-notes.md`). Confirm the file appears, with matched characters emphasized. -4. Type a phrase you know appears inside exactly one note and nowhere in any file name. Confirm a content result appears with the right line number and a readable excerpt. +4. Type a phrase you know appears inside exactly one note and nowhere in any file name. Confirm a content result appears with a readable excerpt and inline Markdown syntax stripped. 5. Press `Enter` on that content result. Confirm the palette closes and the correct Markdown File opens. 6. Type a query matching many files. Confirm results appear promptly and, if capped, that the palette says results were capped. 7. Type quickly and continuously. Confirm no stale result set ever replaces a newer one, and the palette never flickers back to older matches. diff --git a/specs/gh-117/TECH.md b/specs/gh-117/TECH.md index 1f630a8..7c39e20 100644 --- a/specs/gh-117/TECH.md +++ b/specs/gh-117/TECH.md @@ -49,7 +49,7 @@ Add to `DesktopApi` (`apps/desktop/src/desktopApi/types.ts`), preload (`electron ```ts export type SearchContentMatch = { line: number; // 1-indexed - excerpt: string; // trimmed line, window around the first match + excerpt: string; // stripped inline Markdown, window around the first match matchStart: number; // offset into excerpt matchEnd: number; }; @@ -79,7 +79,7 @@ Main-process implementation: - `stat` each candidate; skip when `size > MAX_FILE_BYTES`. - Read with a concurrency pool of `SEARCH_CONCURRENCY`. - Match case-insensitively on a literal needle. No regex: it avoids ReDoS on user input and avoids the question of which dialect we promise. -- Per file, collect up to `MAX_MATCHES_PER_FILE` matches, each with line number and an excerpt windowed to `EXCERPT_CONTEXT_CHARS` either side of the match. +- Per file, collect up to `MAX_MATCHES_PER_FILE` matches, each with a payload line number and an excerpt with inline Markdown syntax stripped, windowed to `EXCERPT_CONTEXT_CHARS` either side of the match. The palette does not render the line number until jump-to-line is implemented. - Stop after `MAX_RESULT_FILES` files have matched, and set `truncated: true`. Constants, in one place in main: `MAX_FILE_BYTES = 2 * 1024 * 1024`, `MAX_RESULT_FILES = 50`, `MAX_MATCHES_PER_FILE = 3`, `SEARCH_CONCURRENCY = 8`, `EXCERPT_CONTEXT_CHARS = 40`. @@ -144,7 +144,7 @@ Desktop (running app; use the `test-desktop-app` skill when automating): 1. Open a folder with nested Markdown Files. `CmdOrCtrl+P` opens the palette, focused and empty, showing recently modified files. `Escape` closes it and restores focus. 2. Type a subsequence of a file name with characters omitted; confirm the file ranks and matched characters are emphasized. Confirm a basename match outranks a parent-folder-only match. -3. Type a phrase present in exactly one note's body and in no file name; confirm one content result with the correct line number and a readable excerpt. `Enter` opens that file. +3. Type a phrase present in exactly one note's body and in no file name; confirm one content result with a readable excerpt and inline Markdown syntax stripped. `Enter` opens that file. 4. Type continuously and fast; confirm no older result set ever replaces a newer one, and name results never blank out while content search runs. 5. Create a folder with more than 50 files containing a common phrase; confirm the capped-results message appears. 6. Add a note to `.gitignore`, run `File > Sync Workspace`, and confirm the note disappears from both sidebar and search.