Trawler#
A personal, keyboard-first, Rust-native outliner in the mold of Logseq DB: a CRDT-backed block graph, a journal-first navigation model, and a Steel (Scheme) query engine for live, first-class queries over your notes.
Built with GPUI (Zed's UI framework), Loro (CRDT storage), Steel (embedded Scheme), and tantivy (full-text search).
Status#
This is the trawler-mvp change (see openspec/changes/trawler-mvp/) — a
single-user MVP, not a released product. Phases 1–7 (block graph core, search,
Steel queries, the outline editor, journal/navigation, and query blocks) are
implemented; Phase 8 (hardening) is in progress. See
openspec/changes/trawler-mvp/tasks.md for the exact task-by-task status and
design.md for the architecture decisions and known deviations from the
original spec.
Building and running#
Requires the Rust toolchain pinned in rust-toolchain.toml (currently
1.94.0, x86_64-pc-windows-msvc) — rustup will pick it up automatically.
Windows is the only platform this has been built and run on; GPUI's Windows
backend is confirmed to use real Direct3D 11 rendering (see design.md Spike
S1), but other platforms are untested.
cargo run -p trawler
On first launch, this creates a graph directory and opens straight to today's journal page, focused and ready to type.
Development commands:
cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo fmt --all
Some tests are expensive (they build synthetic 100k-block graphs) and are
#[ignore]d by default:
cargo test -p trawler-core --release -- --ignored
Where your data lives#
By default the graph directory is %APPDATA%\trawler\graph. Override it with
the TRAWLER_GRAPH_DIR environment variable (useful for running multiple
graphs, or a scratch graph while developing):
TRAWLER_GRAPH_DIR=/tmp/my-test-graph cargo run -p trawler
Graph directory format#
Everything under the graph directory is derived from, or is, a single Loro
CRDT document — there is no separate database. Deleting any file except
snapshot.loro/updates.log and re-launching rebuilds it from scratch.
<graph-dir>/
snapshot.loro # the last compacted full snapshot of the Loro doc
updates.log # length-prefixed incremental update blobs since the
# last snapshot — replayed on top of it when opening
search-index/ # tantivy full-text index; entirely disposable,
# rebuilt automatically if missing
snapshot.loro+updates.logare the only source of truth. Every other file (the search index) can be deleted safely; it's rebuilt transparently on next open.- The Loro document holds one movable tree (container name
"outline"). Tree roots are pages; everything else is a block. A block's text lives in its metadata map under thecontentkey (a mergeable Loro text); typed properties (used by query-blocktable/prop/prop-eq, and thequeryflag that marks a block as a query block) live underproperties. - Writes are append-only and crash-safe:
updates.logentries are length-prefixed,fsync'd before a write is considered acknowledged, and a torn trailing write (a kill mid-append) is detected and simply dropped on next open rather than treated as corruption. - There's no explicit "compact" action wired into the UI yet — the update
log only shrinks if
GraphStorage::compact()is called, which currently only happens implicitly in tests. On a long-running graph this log can grow large enough to make the next cold start noticeably slower (see "Performance notes" below) — a good Phase-8-adjacent follow-up would be an idle-time or startup-triggered auto-compact.
Keyboard reference#
Global:
| Key | Action |
|---|---|
Ctrl+K |
Quick open — fuzzy switcher over pages, tags, and journal-date shortcuts |
Ctrl+F |
Full-text search |
Alt+Left / Alt+Right |
Navigation back / forward |
Ctrl+. / Ctrl+, |
Zoom into / out of the focused block |
Ctrl+Enter |
Create a page from an uncreated tag/page/date view |
Ctrl+Shift+Q |
Toggle the focused block as a query block |
Ctrl+Shift+C |
Open the calendar picker |
Back/forward and the calendar are also available as clickable buttons in the header (◀ / ▶ / "Calendar") for mouse-driven navigation — the calendar opens to the current month; click a day to jump to (or create) its journal page, click ◀/▶ in the calendar itself to change months. Days that already have a journal page are bolded; today is highlighted.
While editing a block:
| Key | Action |
|---|---|
Enter |
Split into a new sibling block at the cursor |
Shift+Enter |
Insert a newline within the block |
Tab / Shift+Tab |
Indent / outdent under the previous sibling |
Backspace at start of block |
Merge into the end of the previous sibling |
Alt+Up / Alt+Down |
Move the block up/down among its siblings |
Up / Down at a content boundary |
Move focus to the previous/next visible block |
Ctrl+Enter |
Follow the reference at/nearest the cursor |
[[ / # |
Open reference/tag completion |
Escape |
Dismiss an open completion popup |
Standard text editing (arrows, shift-select, Ctrl+A/C/V/X, Home/End)
works as expected within a block.
Query blocks#
Any block can become a query block: press Ctrl+Shift+Q while it's
focused, then write a Steel (Scheme) expression as its content. The result
renders live beneath it, re-evaluating a few hundred milliseconds after you
stop typing, without leaving the block.
A query evaluates in a read-only, capability-scoped Steel VM — only the primitives below are registered; there is no way to mutate the graph, touch the filesystem, or reach the network from a query. A runaway (non-terminating) query is interrupted after its time budget and shown as "Query timed out" rather than freezing the app.
Primitives#
Each of these returns a set (as a sorted list of block ids) unless noted:
| Primitive | Returns |
|---|---|
(tag 'name) |
Blocks tagged #name |
(ref "name-or-id") |
Blocks referencing that page, tag, date, or block id |
(prop "key") |
Blocks with property key set |
(prop-eq "key" "value") |
Blocks where property key equals value |
(date-range "2026-07-01" "2026-07-31") |
Blocks referencing a date in that range |
(descendants "peer@counter") |
Descendants of the given block id |
(search "text") |
Full-text search hits for text |
(and set set) / (or set set) / (not set) |
Native set intersection/union/complement |
(table set '("col1" "col2")) |
Projects each block's named properties into a table result instead of a plain list |
(filter (lambda (id) ...) set) / (map f set) |
Per-block Scheme predicate/transform over an already-narrowed set |
Plus a small stdlib: car, cdr, cons, null?, bool-not (note: not is
the set-complement primitive above, not boolean negation — use bool-not for
that), string-append, string-contains?, string-equal?, and
+ - * / = < > <= >=.
Examples#
; Everything tagged #project that also references [[rust]]
(and (tag 'project) (ref "rust"))
; Same set, projected as a table with due/priority columns
(table (tag 'project) '("due" "priority"))
; Blocks referencing a date in July, excluding anything tagged #done
(and (date-range "2026-07-01" "2026-07-31") (not (tag 'done)))
; Full-text search composed with a tag filter
(and (search "bycatch") (tag 'research))
Known deviations from the original spec#
Documented in more detail in design.md's Decisions/Open Questions and
inline in the relevant source files; summarized here:
- Quick-open matching is substring, not true fuzzy matching.
fuzzy_containsis a case-insensitive substring check, not a scored fuzzy algorithm (no subsequence matching, no ranking by match quality). The spec's own example scenario (trawl→trawler-design) happens to be a substring match, so it passes, but a query liketwdsnwould not matchtrawler-designthe way a real fuzzy matcher would. - Query-block syntax highlighting is a hand-rolled tokenizer
(
trawler::scheme_highlight), not tree-sitter as the spec names. See design.md D4 for the rationale — query blocks are short single expressions, not source files, so a real incremental-reparse tree-sitter grammar buys little here. gpui-componentwas dropped mid-project in favor of a customeditor::BlockEditorbuilt directly on GPUI's raw primitives. The original design leaned on it for the one-hot editor; its baked-in Enter/Tab/Backspace keybindings and a default-width bug fighting the outline's own semantics made it more friction than help. See design.md D3.- Cold start at large scale exceeds the original ~1s budget. Spike S3's
cold-start number (Loro doc load + a stand-in index rebuild) came in at
~667ms at 100k blocks, but that stand-in didn't account for the real
GraphIndex::rebuild(which parses references out of every block's content) or building the tantivy search index from scratch. Measuring with the actual code at 100k blocks: doc load ~0.2–0.9s (much slower uncompacted — see the graph-format note above),GraphIndex::rebuild~0.6–1.1s,SearchIndexbuild ~0.5s — roughly 1.5–2s total, over budget. The two rebuilds are independent of each other and currently run sequentially; running them concurrently (they don't share mutable state) is the obvious first optimization if this matters before a real corpus gets that large. Personal note-taking corpora are expected to stay well under 100k blocks for a long time, so this isn't blocking, but it's a real number, not the original hoped-for one.
Project layout#
crates/
trawler-core/ # block graph, Loro storage, indexes, Steel query engine,
# search — no UI dependencies, fully testable headless
trawler/ # GPUI app: outline editor, journal view, navigation,
# query block UI