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
Dev automation (devtools)#
For development — especially agent-assisted development — trawler has an optional automation mode: headless UI tests, a deterministic fixture graph, and a local socket for driving and observing a running instance. None of it exists in a default build.
UI tests#
cargo test --workspace includes gpui integration tests
(crates/trawler/src/ui_tests.rs) that open the real TrawlerApp over a
seeded fixture graph on gpui's fake test platform — no window, no GPU — and
drive it through the real keymap with simulated keystrokes. They run
identically on every OS and in CI.
Fixture graphs#
trawler-core's fixtures feature provides a deterministic seeded graph
(fixed dates, fixed content, even stable block ids) shared by the UI tests
and interactive dev sessions, so state dumps and screenshots are comparable
across machines. Seed one from the CLI (devtools build):
cargo run -p trawler --features devtools -- --seed-fixtures /tmp/scratch-graph
Automation server#
Build with --features devtools and launch with TRAWLER_DEVTOOLS=1 (both
required — a devtools binary run normally opens no sockets):
TRAWLER_DEVTOOLS=1 TRAWLER_GRAPH_DIR=/tmp/scratch-graph \
cargo run -p trawler --features devtools
The app binds a TCP listener on 127.0.0.1 (OS-assigned port, printed to
stdout and written to <graph-dir>/devtools.port) speaking one JSON object
per line, one response per line:
| Request | Effect |
|---|---|
{"cmd":"keys","keys":"ctrl-k"} |
Dispatch keystrokes through the real keymap (Keystroke::parse syntax, whitespace-separated). No OS focus needed. |
{"cmd":"type","text":"hello [["} |
Insert text through the focused editor's input path (triggers completion, query re-eval, etc.). |
{"cmd":"dump"} |
Versioned JSON of semantic UI state: view, visible rows, focused block/cursor/selection, open popups, window bounds. |
{"cmd":"bounds"} |
Window position/size/scale only. |
{"cmd":"screenshot","path":"shot.png"} |
Capture the app window to a PNG. |
Failures answer {"ok":false,"error":...} and never affect the app. Replies
are sent after the dispatched action has run; poll dump for state that
settles asynchronously (e.g. query re-evaluation after its debounce).
Screenshots are best-effort per platform: solid on Windows and X11, macOS needs a one-time Screen Recording permission, and Linux Wayland depends on the compositor — everything else keeps working where capture doesn't. (Implementation note: capture runs in a short-lived helper process because xcap won't enumerate the calling process's own windows on Windows.)
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