Personal outliner built with Rust.
3

Configure Feed

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

phase 8.2-8.4: performance pass, README, design.md reconciliation

- README.md: build/run, graph directory format, keyboard reference, query language reference with examples, documented deviations from spec
- design.md: D3 rewritten to reflect gpui-component removal; Risks section updated; new Reconciliation section
- performance pass finding: cold start at 100k blocks is ~1.5-2s using the real GraphIndex::rebuild + SearchIndex (not Spike S3's simplified stand-in), over the original ~1s budget — documented as a known gap, not fixed (personal-notebook scale is expected to stay well under 100k blocks)

8.1 (week-long dogfood checkpoint) intentionally left unchecked — requires real usage over time.

graham.systems (Jul 12, 2026, 2:52 PM -0700) 969eab13 92395638

+262 -11
+221
README.md
··· 1 + # Trawler 2 + 3 + A personal, keyboard-first, Rust-native outliner in the mold of Logseq DB: a 4 + CRDT-backed block graph, a journal-first navigation model, and a Steel 5 + (Scheme) query engine for live, first-class queries over your notes. 6 + 7 + Built with [GPUI](https://www.gpui.rs/) (Zed's UI framework), [Loro](https://loro.dev/) 8 + (CRDT storage), [Steel](https://github.com/mattwparas/steel) (embedded Scheme), 9 + and [tantivy](https://github.com/quickwit-oss/tantivy) (full-text search). 10 + 11 + ## Status 12 + 13 + This is the `trawler-mvp` change (see `openspec/changes/trawler-mvp/`) — a 14 + single-user MVP, not a released product. Phases 1–7 (block graph core, search, 15 + Steel queries, the outline editor, journal/navigation, and query blocks) are 16 + implemented; Phase 8 (hardening) is in progress. See 17 + `openspec/changes/trawler-mvp/tasks.md` for the exact task-by-task status and 18 + `design.md` for the architecture decisions and known deviations from the 19 + original spec. 20 + 21 + ## Building and running 22 + 23 + Requires the Rust toolchain pinned in `rust-toolchain.toml` (currently 24 + `1.94.0`, `x86_64-pc-windows-msvc`) — `rustup` will pick it up automatically. 25 + Windows is the only platform this has been built and run on; GPUI's Windows 26 + backend is confirmed to use real Direct3D 11 rendering (see design.md Spike 27 + S1), but other platforms are untested. 28 + 29 + ```sh 30 + cargo run -p trawler 31 + ``` 32 + 33 + On first launch, this creates a graph directory and opens straight to 34 + today's journal page, focused and ready to type. 35 + 36 + Development commands: 37 + 38 + ```sh 39 + cargo check --workspace 40 + cargo clippy --workspace --all-targets -- -D warnings 41 + cargo test --workspace 42 + cargo fmt --all 43 + ``` 44 + 45 + Some tests are expensive (they build synthetic 100k-block graphs) and are 46 + `#[ignore]`d by default: 47 + 48 + ```sh 49 + cargo test -p trawler-core --release -- --ignored 50 + ``` 51 + 52 + ### Where your data lives 53 + 54 + By default the graph directory is `%APPDATA%\trawler\graph`. Override it with 55 + the `TRAWLER_GRAPH_DIR` environment variable (useful for running multiple 56 + graphs, or a scratch graph while developing): 57 + 58 + ```sh 59 + TRAWLER_GRAPH_DIR=/tmp/my-test-graph cargo run -p trawler 60 + ``` 61 + 62 + ## Graph directory format 63 + 64 + Everything under the graph directory is derived from, or is, a single Loro 65 + CRDT document — there is no separate database. Deleting any file *except* 66 + `snapshot.loro`/`updates.log` and re-launching rebuilds it from scratch. 67 + 68 + ``` 69 + <graph-dir>/ 70 + snapshot.loro # the last compacted full snapshot of the Loro doc 71 + updates.log # length-prefixed incremental update blobs since the 72 + # last snapshot — replayed on top of it when opening 73 + search-index/ # tantivy full-text index; entirely disposable, 74 + # rebuilt automatically if missing 75 + ``` 76 + 77 + - **`snapshot.loro` + `updates.log` are the only source of truth.** Every 78 + other file (the search index) can be deleted safely; it's rebuilt 79 + transparently on next open. 80 + - The Loro document holds one movable tree (container name `"outline"`). 81 + Tree roots are pages; everything else is a block. A block's text lives in 82 + its metadata map under the `content` key (a mergeable Loro text); typed 83 + properties (used by query-block `table`/`prop`/`prop-eq`, and the `query` 84 + flag that marks a block as a query block) live under `properties`. 85 + - Writes are append-only and crash-safe: `updates.log` entries are 86 + length-prefixed, `fsync`'d before a write is considered acknowledged, and a 87 + torn trailing write (a kill mid-append) is detected and simply dropped on 88 + next open rather than treated as corruption. 89 + - There's no explicit "compact" action wired into the UI yet — the update 90 + log only shrinks if `GraphStorage::compact()` is called, which currently 91 + only happens implicitly in tests. On a long-running graph this log can grow 92 + large enough to make the next cold start noticeably slower (see 93 + "Performance notes" below) — a good Phase-8-adjacent follow-up would be an 94 + idle-time or startup-triggered auto-compact. 95 + 96 + ## Keyboard reference 97 + 98 + **Global:** 99 + 100 + | Key | Action | 101 + |---|---| 102 + | `Ctrl+K` | Quick open — fuzzy switcher over pages, tags, and journal-date shortcuts | 103 + | `Ctrl+F` | Full-text search | 104 + | `Alt+Left` / `Alt+Right` | Navigation back / forward | 105 + | `Ctrl+.` / `Ctrl+,` | Zoom into / out of the focused block | 106 + | `Ctrl+Enter` | Create a page from an uncreated tag/page/date view | 107 + | `Ctrl+Shift+Q` | Toggle the focused block as a query block | 108 + 109 + **While editing a block:** 110 + 111 + | Key | Action | 112 + |---|---| 113 + | `Enter` | Split into a new sibling block at the cursor | 114 + | `Shift+Enter` | Insert a newline within the block | 115 + | `Tab` / `Shift+Tab` | Indent / outdent under the previous sibling | 116 + | `Backspace` at start of block | Merge into the end of the previous sibling | 117 + | `Alt+Up` / `Alt+Down` | Move the block up/down among its siblings | 118 + | `Up` / `Down` at a content boundary | Move focus to the previous/next visible block | 119 + | `Ctrl+Enter` | Follow the reference at/nearest the cursor | 120 + | `[[` / `#` | Open reference/tag completion | 121 + | `Escape` | Dismiss an open completion popup | 122 + 123 + Standard text editing (arrows, shift-select, `Ctrl+A`/`C`/`V`/`X`, Home/End) 124 + works as expected within a block. 125 + 126 + ## Query blocks 127 + 128 + Any block can become a **query block**: press `Ctrl+Shift+Q` while it's 129 + focused, then write a Steel (Scheme) expression as its content. The result 130 + renders live beneath it, re-evaluating a few hundred milliseconds after you 131 + stop typing, without leaving the block. 132 + 133 + A query evaluates in a read-only, capability-scoped Steel VM — only the 134 + primitives below are registered; there is no way to mutate the graph, touch 135 + the filesystem, or reach the network from a query. A runaway (non-terminating) 136 + query is interrupted after its time budget and shown as "Query timed out" 137 + rather than freezing the app. 138 + 139 + ### Primitives 140 + 141 + Each of these returns a **set** (as a sorted list of block ids) unless noted: 142 + 143 + | Primitive | Returns | 144 + |---|---| 145 + | `(tag 'name)` | Blocks tagged `#name` | 146 + | `(ref "name-or-id")` | Blocks referencing that page, tag, date, or block id | 147 + | `(prop "key")` | Blocks with property `key` set | 148 + | `(prop-eq "key" "value")` | Blocks where property `key` equals `value` | 149 + | `(date-range "2026-07-01" "2026-07-31")` | Blocks referencing a date in that range | 150 + | `(descendants "peer@counter")` | Descendants of the given block id | 151 + | `(search "text")` | Full-text search hits for `text` | 152 + | `(and set set)` / `(or set set)` / `(not set)` | Native set intersection/union/complement | 153 + | `(table set '("col1" "col2"))` | Projects each block's named properties into a table result instead of a plain list | 154 + | `(filter (lambda (id) ...) set)` / `(map f set)` | Per-block Scheme predicate/transform over an already-narrowed set | 155 + 156 + Plus a small stdlib: `car`, `cdr`, `cons`, `null?`, `bool-not` (note: `not` is 157 + the set-complement primitive above, not boolean negation — use `bool-not` for 158 + that), `string-append`, `string-contains?`, `string-equal?`, and 159 + `+ - * / = < > <= >=`. 160 + 161 + ### Examples 162 + 163 + ```scheme 164 + ; Everything tagged #project that also references [[rust]] 165 + (and (tag 'project) (ref "rust")) 166 + 167 + ; Same set, projected as a table with due/priority columns 168 + (table (tag 'project) '("due" "priority")) 169 + 170 + ; Blocks referencing a date in July, excluding anything tagged #done 171 + (and (date-range "2026-07-01" "2026-07-31") (not (tag 'done))) 172 + 173 + ; Full-text search composed with a tag filter 174 + (and (search "bycatch") (tag 'research)) 175 + ``` 176 + 177 + ## Known deviations from the original spec 178 + 179 + Documented in more detail in `design.md`'s Decisions/Open Questions and 180 + inline in the relevant source files; summarized here: 181 + 182 + - **Quick-open matching is substring, not true fuzzy matching.** `fuzzy_contains` 183 + is a case-insensitive substring check, not a scored fuzzy algorithm (no 184 + subsequence matching, no ranking by match quality). The spec's own example 185 + scenario (`trawl` → `trawler-design`) happens to be a substring match, so it 186 + passes, but a query like `twdsn` would not match `trawler-design` the way a 187 + real fuzzy matcher would. 188 + - **Query-block syntax highlighting is a hand-rolled tokenizer** 189 + (`trawler::scheme_highlight`), not tree-sitter as the spec names. See 190 + design.md D4 for the rationale — query blocks are short single expressions, 191 + not source files, so a real incremental-reparse tree-sitter grammar buys 192 + little here. 193 + - **`gpui-component` was dropped mid-project** in favor of a custom 194 + `editor::BlockEditor` built directly on GPUI's raw primitives. The original 195 + design leaned on it for the one-hot editor; its baked-in Enter/Tab/Backspace 196 + keybindings and a default-width bug fighting the outline's own semantics 197 + made it more friction than help. See design.md D3. 198 + - **Cold start at large scale exceeds the original ~1s budget.** Spike S3's 199 + cold-start number (Loro doc load + a stand-in index rebuild) came in at 200 + ~667ms at 100k blocks, but that stand-in didn't account for the real 201 + `GraphIndex::rebuild` (which parses references out of every block's 202 + content) or building the tantivy search index from scratch. Measuring with 203 + the actual code at 100k blocks: doc load ~0.2–0.9s (much slower 204 + uncompacted — see the graph-format note above), `GraphIndex::rebuild` 205 + ~0.6–1.1s, `SearchIndex` build ~0.5s — roughly **1.5–2s total**, over 206 + budget. The two rebuilds are independent of each other and currently run 207 + sequentially; running them concurrently (they don't share mutable state) 208 + is the obvious first optimization if this matters before a real corpus 209 + gets that large. Personal note-taking corpora are expected to stay well 210 + under 100k blocks for a long time, so this isn't blocking, but it's a real 211 + number, not the original hoped-for one. 212 + 213 + ## Project layout 214 + 215 + ``` 216 + crates/ 217 + trawler-core/ # block graph, Loro storage, indexes, Steel query engine, 218 + # search — no UI dependencies, fully testable headless 219 + trawler/ # GPUI app: outline editor, journal view, navigation, 220 + # query block UI 221 + ```
+37 -7
openspec/changes/trawler-mvp/design.md
··· 49 49 - *SQLite* — remains the boring fallback if the in-memory index ever needs persistence. 50 50 - **Owning the in-memory graph wins for a Steel-specific reason**: query quality depends on how tightly Scheme primitives map to index operations. Owning the structure lets `(and ...)` compile to native set intersection rather than composing through a third party's API assumptions. 51 51 52 - ### D3: GPUI + gpui-component for the shell; one-hot block editor 52 + ### D3: GPUI for the shell; custom one-hot block editor 53 53 54 54 Exactly one live text input exists at any time — the focused block. All other blocks are read-only rendered views (styled Markdown). Focus movement detaches the editor, re-renders the block, reattaches to the target. 55 55 56 - - **Why one-hot**: this is Logseq's own pattern and the cheat code that makes a native-GUI outliner tractable — one good single-block input (gpui-component provides the base) plus a fast rendered view, instead of a Zed-grade multi-buffer editor. 57 - - **Why GPUI**: "feels like Zed" is a stated requirement, and GPUI *is* that feel. `gpui-component` supplies inputs, lists, scrollbars. Helix/Zed precedents exist for the tree-sitter integration Steel editing needs. 56 + - **Why one-hot**: this is Logseq's own pattern and the cheat code that makes a native-GUI outliner tractable — one good single-block input plus a fast rendered view, instead of a Zed-grade multi-buffer editor. 57 + - **Why GPUI**: "feels like Zed" is a stated requirement, and GPUI *is* that feel. 58 58 - **Risk accepted**: GPUI's Windows backend is its youngest and docs are thin; the developer is on Windows. Gated by Spike S1. 59 + - **Revised mid-project: `gpui-component` was dropped (task 5.5).** The original plan (below, in the S1 findings) used `gpui-component`'s `Input` for the one-hot editor. In practice its own baked-in Enter/Tab/Backspace keybindings fought every attempt to give those keys outline semantics (split/indent/merge) instead of textarea semantics — GPUI resolves the deepest/most-specific (keystroke, context) binding first, and there's no way to have an ancestor context offer a *different* action for a keystroke the focused element's own context already claims. Combined with a default-width bug that rendered the editor as a collapsed sliver, and a fix for one visual bug (a dropdown arrow) that ended up center-aligning all body text app-wide, continuing to fight the component stopped being worth it. `editor::BlockEditor` (in `crates/trawler`) replaces it: built directly on GPUI's own `EntityInputHandler`/`Element` primitives (modeled on GPUI's own `examples/input.rs`, extended to multiple lines), owning 100% of its keybindings with no third-party defaults to override. This also ended up being the base the query-block syntax highlighting (D4, task 7.2) hooks into — highlighting a `gpui-component` `Input`'s text wasn't an option that existed. 59 60 60 61 ### D4: Steel (Scheme) as the query language, capability-scoped 61 62 ··· 99 100 100 101 ## Risks / Trade-offs 101 102 102 - - [GPUI Windows backend is young; breakage or missing pieces on the primary dev machine] → Spike S1 first: stand up a GPUI window with gpui-component input on Windows before any other UI work. Fallback documented if blocked: re-evaluate frontend (Tauri) rather than abandon — core is UI-independent. 103 + - [GPUI Windows backend is young; breakage or missing pieces on the primary dev machine] → Spike S1 first: stand up a GPUI window on Windows before any other UI work. Fallback documented if blocked: re-evaluate frontend (Tauri) rather than abandon — core is UI-independent. 103 104 - [Steel eval can't be cleanly interrupted → runaway query freezes previews] → Spike S2: prove background-thread eval with cancellation/timeout. Fallback: run query VM in a dedicated thread that can be dropped/respawned; queries are stateless so a killed VM costs nothing. 104 - - [Loro doc grows unboundedly / rebuild-at-startup becomes slow at real scale] → Spike S3: synthetic 100k-block graph; measure snapshot size, load time, index rebuild time. Loro supports snapshot compaction; budget: cold start under ~1s at 100k blocks. 105 - - [One-hot editor feels laggy on focus transitions (detach/render/reattach per keystroke navigation)] → treat editor transition latency as a benchmark from the first prototype; pre-render adjacent blocks if needed. 106 - - [gpui/gpui-component API churn (pre-1.0)] → pin revisions; vendor patches if needed; upgrade deliberately, not continuously. 105 + - [Loro doc grows unboundedly / rebuild-at-startup becomes slow at real scale] → Spike S3: synthetic 100k-block graph; measure snapshot size, load time, index rebuild time. Loro supports snapshot compaction; budget: cold start under ~1s at 100k blocks. **Miss found in the task 8.2 performance pass**: measuring with the real `GraphIndex::rebuild` and `SearchIndex` (S3's own rebuild was a simplified stand-in that didn't parse references or build a search index) puts cold start at 100k blocks around 1.5–2s, not under 1s — see README.md "Known deviations" for the numbers and the obvious first fix (the two rebuilds are independent and currently run sequentially). 106 + - [One-hot editor feels laggy on focus transitions (detach/render/reattach per keystroke navigation)] → treat editor transition latency as a benchmark from the first prototype; pre-render adjacent blocks if needed. No dedicated benchmark was built; the custom `BlockEditor` (D3) reuses the same entity across a session and only reconstructs on focus change, and no latency complaints surfaced during interactive verification, but this was never measured numerically. 107 + - [`gpui-component` API churn (pre-1.0)] → moot: dropped entirely (D3). GPUI itself is still pinned (`gpui = 0.2.2`) and not upgraded during the project. 107 108 - [Steel pre-1.0 API changes] → pin; the query API surface is ours, so churn is contained to the embedding layer. 108 109 - [Everything-is-derived means index bugs silently corrupt query results] → index rebuild is cheap and deterministic: golden tests compare incremental index state against full rebuild after randomized edit sequences. 109 110 ··· 115 116 116 117 - **Query surface shape**: datalog-style relations as Scheme macros vs. functional set combinators (current lean: combinators first — they fall out of the API design; a relational macro layer can be pure Scheme on top later). 117 118 - ~~**Similar-blocks UX**: where do suggestions surface (dedicated panel vs. inline footer on focused block)? Defer to prototype feedback.~~ **Resolved (task 6.8)**: dedicated panel, refreshed on every focus change and shown below the backlinks panel whenever a block is focused. An inline footer would have meant every row renderer (Markdown view, `BlockEditor`, `gpui::list`'s per-item height accounting) needed to know about a second kind of variable-height footer alongside backlinks; a shared panel reused the pattern (and the snippet-rendering code) already built for backlinks/search with no new plumbing. 119 + 120 + ## Reconciliation (task 8.4, 2026-07-04) 121 + 122 + A pass comparing every requirement/scenario in `specs/*/spec.md` against the 123 + implementation. Full detail lives in README.md's "Known deviations" section 124 + and inline in the relevant source; summarized here: 125 + 126 + - **Met as specified**: block graph core (2.x), search (3.x), Steel query 127 + engine (4.x) including capability scoping and containment, the outline 128 + editor and keybindings (5.x), journal/navigation (6.x), and query blocks 129 + (7.x) — all have passing tests exercising their spec scenarios directly 130 + (test names reference the scenario they cover). 131 + - **Met, with a documented simplification**: quick open's fuzzy matching 132 + (substring, not scored/subsequence matching — the spec's own example 133 + scenario happens to be a substring match, so it passes, but the general 134 + "fuzzy-matches" wording is broader than the implementation). 135 + Query-block syntax highlighting (hand-rolled tokenizer, not tree-sitter — 136 + see D4). 137 + - **Architecture changed from the original plan, requirement still met**: 138 + the one-hot editor no longer uses `gpui-component` (see D3); the outline 139 + editor requirements themselves (spec: outline-editor) are unaffected by 140 + which primitives implement them. 141 + - **Budget miss found during the pass**: cold start at 100k blocks (see the 142 + Spike S3 risk entry above and README.md). Not a functional gap, but the 143 + original ~1s number was optimistic once the real index/search rebuild 144 + cost is included. 145 + - **Not built**: 8.1 (week-long dogfood checkpoint) requires the user 146 + actually using the app daily over real time; nothing to reconcile against 147 + specs yet since it hasn't happened. 118 148 119 149 ## Spike Findings (2026-07-02, go/no-go checkpoint) 120 150
+4 -4
openspec/changes/trawler-mvp/tasks.md
··· 65 65 66 66 ## 8. Hardening & Wrap-up 67 67 68 - - [ ] 8.1 Dogfood checkpoint: use trawler as the daily driver for one week; file and triage findings 69 - - [ ] 8.2 Performance pass against spec budgets (editing latency, query latency, index freshness, cold start) 70 - - [ ] 8.3 Documentation: README (build/run), graph directory format notes, query language reference with examples 71 - - [ ] 8.4 Reconcile artifacts: update design.md decisions/open questions to match reality; verify specs against implementation 68 + - [ ] 8.1 Dogfood checkpoint: use trawler as the daily driver for one week; file and triage findings — **needs the user**, not something that can be done from this side; nothing to check off until a week of real usage has happened 69 + - [x] 8.2 Performance pass against spec budgets (editing latency, query latency, index freshness, cold start) — see design.md Reconciliation and README "Known deviations"; cold start at 100k blocks is the one real budget miss found 70 + - [x] 8.3 Documentation: README (build/run), graph directory format notes, query language reference with examples 71 + - [x] 8.4 Reconcile artifacts: update design.md decisions/open questions to match reality; verify specs against implementation