The ⌘K switcher shows two sections, in this order:
- Titles: matched against the in-memory page list. Synchronous and always current, including titles that haven't been saved yet.
- Content: matched against page bodies from storage. Async, backed by the index.
A map of page id → Markdown body, frontmatter stripped so queries don't match ids or timestamps. No tokens or postings lists (see No inverted index). Titles aren't in it.
The Rust and TypeScript frontmatter strips are kept in step by
strip_frontmatter_matches_the_typescript_split in src-tauri/src/scan.rs.
- Desktop: during the startup scan (
scan_notesinsrc-tauri/src/scan.rs), which already reads every file. No extra I/O. - Web: on the first search, then updated in place.
| Event | Desktop | Web |
|---|---|---|
| Page saved | write_page re-indexes the page |
put() updates the cached body |
| Page moved, restored, renamed | Same; every write goes through atomicWrite with the page id |
Same; every write goes through put() |
| Page trashed or deleted | Nothing (see below) | deleteForever drops the body |
| Notes folder changed | Next scan replaces the map | Store is rebuilt |
| Edited outside Set | The folder watch (watch.rs) triggers a rescan |
n/a |
write_pagere-indexes only after the write succeeds, so the index never holds text that isn't on disk.- Deleted pages aren't evicted.
Workspace.searchContentpasses the live page ids asonly, which covers trash, deletion and stale entries. A restored page is searchable immediately. onlyis applied before the limit, so pages outside the scope can't take up the 20 slots. The switcher narrows it further to the active context, minus pages already listed by title.- Searches take a read lock and run off the main thread (
#[tauri::command(async)]), so a long scan doesn't freeze the window. A save waits only for searches already running. - Opening the switcher flushes pending autosave (
Workspace.openQuickSwitcher), so unsaved edits are searchable.loadBacklinksflushes too.
src/lib/search/titles.ts, over workspace.pages. Tiers are tried in order and the first match
wins:
| Tier | Matches | Example, query alpha |
|---|---|---|
| exact | title is the query | Alpha |
| prefix | title starts with it | Alpha Rollout |
| word-prefix | a word in the title starts with it | Project Alpha |
| all-terms word-prefix | every term starts a word, any order (multi-term) | proj alph → Project Alpha |
| substring | anywhere, including mid-word | Cephalopod |
| all-terms | every term appears somewhere (multi-term) | |
| fuzzy | letters in order, not adjacent | pjal → Project Alpha |
| path | an ancestor's title matches | Alpha / Notes |
Ties break on shorter title, then alphabetically, so the order is stable while typing.
src-tauri/src/index.rs (desktop) and src/lib/search/content.ts (web) implement the same
algorithm, kept in step by mirrored test suites.
- Every term must appear in the page (AND).
- Matching is by substring:
serialfindsserializer. - Case folding is ASCII-only:
TODOfindstodo, butCAFÉdoesn't findcafé. This keeps the folded text the same length as the original, so snippet offsets line up (Unicode folding can change length, e.g.İ). Tested on both sides.
| Score component | Weight |
|---|---|
| exact phrase | +60 |
| terms on one line | +20 each |
| repetition | +1 each, capped at 10 |
| position | up to +5, near the top |
Ties break on page id so results don't reorder between identical queries.
The line with the most distinct terms (earliest wins ties), trimmed of indentation and cut to 160
characters around the first hit. It's returned as runs ([{text, hit}]) instead of offsets, because
Rust byte offsets don't match JavaScript string indices. Title highlighting uses the same format.
A page's Linked from list (pages whose body contains a [[wikilink]] to it) comes from the same
id → body map via PageStore.backlinks(title), with no separate index or I/O. It shows under the
title, up to three names, with +N opening the full list (src/lib/page/Backlinks.svelte).
- Results are filtered against the live page list (
Workspace.loadBacklinks), like search. - Matching is by title, since
[[Other]]stores only a title:- Renaming a page changes its backlinks. The list is recomputed when the title changes (debounced).
- If another page answers to the same title (
Workspace.pageIdByTitleprefers the current context), the open page shows no backlinks. - Child pages (
[Title](page:<id>)) aren't backlinks.
wiki_link_titles (index.rs) and wikiLinkTitles (src/lib/search/wiki-links.ts) share one list
of test cases (same_titles_as_the_typescript_scanner / matches the Rust scanner). Both follow the
editor's rule (packages/markdown/src/dialect.ts): drop the alias after | and the section after
#, trim, and skip fenced code blocks and backtick spans.
Known gap: indented (four-space) code blocks aren't skipped.
-
Search runs as you type and matches substrings (
projandjectboth findProjects). A linear scan handles that directly; an inverted index would need n-grams or a radix tree. -
The scan is fast enough.
search_budget_switchermeasures it the way the switcher asks (20 hits, median of 11 runs, release build);search_budget_5000_pagesandsearch_budget_long_notescover the same corpora with every hit returned. All three run in thePerformanceworkflow. Measured on a MacBook Pro (13-inch, M1, 2020: 4 performance + 4 efficiency cores, 16 GB, macOS 14.2.1, Rust 1.95.0):Corpus Query Median 5,000 pages / 8 MB 1 page matches ~9 ms 5,000 pages / 8 MB every page matches ~21 ms 5,000 pages / 8 MB two terms + phrase check ~51 ms 2,000 pages / 20 MB 1 page matches ~23 ms 2,000 pages / 20 MB common term, every page ~53 ms 2,000 pages / 20 MB two terms + phrase check ~125 ms 400 of those (one context) two terms + phrase check ~25 ms -
The time goes to scanning, not ranking or snippets: a two-term query makes about five passes over each matching page (term checks, best line per term, phrase). Building snippets only for the returned hits was tried and made no measurable difference. If the scan needs to get faster, start with
find_ci, which compares one byte at a time. -
Web cost scales with total bytes, not page count. If that changes, revisit.
-
With nothing derived, there's no on-disk cache to fall out of sync with the files.
search_pages runs the same run_search, adds title and breadcrumb matches, and returns one ranked
list. Hits whose title matched are labelled (name matches).
- The
contextargument (also onlist_pages) and the access scope both remove pages beforerun_searchruns, so excluded pages can't affect ranking, counts or timing. Withoutcontext, every context is searched. set-mcpis a separate process with no persistent index. Each call rescans the folder (~70 ms for 5,000 pages), so a page fromcreate_pageis searchable on the next call.
| Path | Role |
|---|---|
src-tauri/src/scan.rs |
Folder walk: page tree and bodies in one pass |
src-tauri/src/index.rs |
Content index and ranking; search_notes, page_backlinks |
src-tauri/src/write.rs |
Atomic page write, re-indexes the page |
src/lib/search/titles.ts |
Title and breadcrumb ranking |
src/lib/search/content.ts |
TypeScript version of index.rs for web |
src/lib/search/wiki-links.ts |
[[wikilink]] parsing, mirrors wiki_link_titles |
src/lib/storage/store.ts |
PageStore.searchContent() and .backlinks() |
src/lib/shell/QuickSwitcher.svelte |
⌘K switcher |
src-tauri/src/mcp/mod.rs |
MCP server, reuses the scan and ranking |
src-tauri/src/mcp/access.rs |
Agent access: on/off, access mode, context scope |
src-tauri/src/mcp/log.rs |
MCP activity log |
src-tauri/src/mcp/write.rs |
Creating pages and contexts (Read & add) |
src-tauri/src/paths.rs |
Filenames and reserved names, mirrors paths.ts |
Tests: src/lib/search/*.test.ts (pnpm test:unit), mod tests in scan.rs, index.rs and
mcp/mod.rs (cargo test), and e2e/search.spec.ts.