Personal outliner built with Rust.
2

Configure Feed

Select the types of activity you want to include in your feed.

21 1 0

Clone this repository

https://tangled.org/graham.systems/trawler https://tangled.org/did:plc:6vq35y564vknuze5bzuptb25
git@tangled.org:graham.systems/trawler git@tangled.org:did:plc:6vq35y564vknuze5bzuptb25

For self-hosted knots, clone URLs may differ based on your setup.



README.md

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.log are 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 the content key (a mergeable Loro text); typed properties (used by query-block table/prop/prop-eq, and the query flag that marks a block as a query block) live under properties.
  • Writes are append-only and crash-safe: updates.log entries 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_contains is a case-insensitive substring check, not a scored fuzzy algorithm (no subsequence matching, no ranking by match quality). The spec's own example scenario (trawltrawler-design) happens to be a substring match, so it passes, but a query like twdsn would not match trawler-design the 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-component was dropped mid-project in favor of a custom editor::BlockEditor built 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, SearchIndex build ~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