Personal outliner built with Rust.
2

Configure Feed

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

Rust 100.0%
22 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

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.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