Personal outliner built with Rust.
0

Configure Feed

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

feat(outline): persist bullet fold state in the document

author graham.systems date (Jul 23, 2026, 11:26 AM -0700) commit 92708fa1 parent aad1f681 change-id pyqxpnmz
+201 -12
+26 -2
crates/trawler-core/src/integrity.rs
··· 90 90 71..=74 => self.remove_prop(outline), 91 91 75..=84 => self.move_subtree(outline), 92 92 85..=91 => self.reorder(outline), 93 + 92..=94 => self.toggle_fold(outline), 93 94 _ => self.delete(outline), 94 95 } 95 96 } ··· 301 302 } 302 303 } 303 304 305 + /// Fold state is document content (openspec change 306 + /// persist-collapsed-state), so the gates cover it like any other 307 + /// mutation. No index events: the flag is invisible to the 308 + /// property/backlink/search indexes by design. 309 + fn toggle_fold(&mut self, outline: &Outline) -> StepOutcome { 310 + let id = self.pick(); 311 + let folded = !outline.folded(id).expect("read fold flag"); 312 + outline.set_folded(id, folded).expect("set_folded"); 313 + StepOutcome { 314 + events: vec![], 315 + content_changed: vec![], 316 + deleted: vec![], 317 + description: format!("fold {id} -> {folded}"), 318 + } 319 + } 320 + 304 321 fn delete(&mut self, outline: &Outline) -> StepOutcome { 305 322 let id = self.pick(); 306 323 let parent = outline.parent(id); ··· 340 357 /// Everything the graph *answers*, flattened into an ordered key → value 341 358 /// map so two states can be compared and the first divergence named. The 342 359 /// keys cover: root order and page names, per-parent child order, every 343 - /// block's content, backlinks per node, tag membership, per-block property 344 - /// values, and search hits for a deterministic content-derived term sample. 360 + /// block's content and fold flag, backlinks per node, tag membership, 361 + /// per-block property values, and search hits for a deterministic 362 + /// content-derived term sample. 345 363 /// Deliberately not a struct-equality check — representation may change, 346 364 /// answers may not (spec: "observable identity"). 347 365 #[derive(Debug, Clone, PartialEq, Eq)] ··· 398 416 } 399 417 } 400 418 entries.insert(format!("content:{id}"), content); 419 + } 420 + // Fold flags are document content (openspec change 421 + // persist-collapsed-state). Folded-only entries, so captures 422 + // of pre-flag graphs compare clean against post-flag ones. 423 + if outline.folded(id).unwrap_or(false) { 424 + entries.insert(format!("folded:{id}"), "true".to_string()); 401 425 } 402 426 } 403 427
+68
crates/trawler-core/src/outline.rs
··· 187 187 } 188 188 } 189 189 190 + /// Set or clear a block's fold flag (spec: block-graph/"Fold state is 191 + /// document content"). A plain `folded` meta key beside `block_type` — 192 + /// deliberately not a `properties` entry, so it never surfaces in 193 + /// property queries or indexes, and last-write-wins merge (the meta 194 + /// map's semantics) is right for a boolean toggle. 195 + pub fn set_folded(&self, id: TreeID, folded: bool) -> LoroResult<()> { 196 + let meta = self.tree().get_meta(id)?; 197 + meta.insert("folded", folded)?; 198 + Ok(()) 199 + } 200 + 201 + /// A block's fold flag. Absent — every block written before the flag 202 + /// existed — reads as expanded, so old graphs open unchanged. 203 + pub fn folded(&self, id: TreeID) -> LoroResult<bool> { 204 + let meta = self.tree().get_meta(id)?; 205 + Ok(matches!( 206 + meta.get("folded"), 207 + Some(loro::ValueOrContainer::Value(loro::LoroValue::Bool(true))) 208 + )) 209 + } 210 + 190 211 /// Set (or overwrite) a typed property on a block. 191 212 pub fn set_property( 192 213 &self, ··· 373 394 374 395 // Removing a key that isn't set must not error. 375 396 outline.remove_property(block, "never-set").unwrap(); 397 + } 398 + 399 + #[test] 400 + fn fold_flag_round_trips_and_defaults_to_expanded() { 401 + let doc = doc_with_tree(); 402 + let outline = Outline::new(&doc); 403 + let page = outline.create_block(None, Position::Index(0), "").unwrap(); 404 + let a = outline 405 + .create_block(Some(page), Position::Index(0), "folded") 406 + .unwrap(); 407 + let b = outline 408 + .create_block(Some(page), Position::Index(1), "expanded") 409 + .unwrap(); 410 + 411 + // Absent key (every pre-flag block) reads as expanded. 412 + assert!(!outline.folded(a).unwrap()); 413 + 414 + outline.set_folded(a, true).unwrap(); 415 + assert!(outline.folded(a).unwrap()); 416 + 417 + // Survives export/import — the same path storage snapshots take. 418 + let bytes = doc.export(loro::ExportMode::Snapshot).unwrap(); 419 + let reopened = LoroDoc::new(); 420 + reopened.import(&bytes).unwrap(); 421 + let ro = Outline::new(&reopened); 422 + assert!(ro.folded(a).unwrap()); 423 + assert!(!ro.folded(b).unwrap()); 424 + 425 + outline.set_folded(a, false).unwrap(); 426 + assert!(!outline.folded(a).unwrap()); 427 + } 428 + 429 + #[test] 430 + fn fold_flag_is_invisible_to_the_property_system() { 431 + let doc = doc_with_tree(); 432 + let outline = Outline::new(&doc); 433 + let page = outline.create_block(None, Position::Index(0), "").unwrap(); 434 + let block = outline 435 + .create_block(Some(page), Position::Index(0), "bulky") 436 + .unwrap(); 437 + 438 + outline.set_folded(block, true).unwrap(); 439 + 440 + // The flag must not leak into `properties()` — that is what the 441 + // property index and queries read (spec: block-graph/"Fold flag is 442 + // invisible to the property system"). 443 + assert!(outline.properties(block).unwrap().is_empty()); 376 444 } 377 445 378 446 #[test]
+32 -2
crates/trawler/src/main.rs
··· 450 450 .expect("open or create search index"), 451 451 ); 452 452 453 + // Fold flags live in the document (spec: outline-editor/"Fold 454 + // state persists across relaunch"); the in-memory set is a derived 455 + // render cache seeded here and kept in step by `toggle_fold`. 456 + let folded = load_folded(&Outline::new(storage.doc())); 457 + 453 458 let mut app = Self { 454 459 graph_dir, 455 460 storage, ··· 461 466 backlinks: Vec::new(), 462 467 breadcrumb: Vec::new(), 463 468 editor: None, 464 - folded: HashSet::new(), 469 + folded, 465 470 quick_open: None, 466 471 quick_open_focus: cx.focus_handle(), 467 472 root_focus: cx.focus_handle(), ··· 688 693 } 689 694 690 695 fn toggle_fold(&mut self, id: TreeID, cx: &mut Context<Self>) { 691 - if !self.folded.remove(&id) { 696 + let folded = !self.folded.remove(&id); 697 + if folded { 692 698 self.folded.insert(id); 693 699 } 700 + // Fold state is document content (spec: block-graph/"Fold state is 701 + // document content"): write the flag and persist as an 702 + // acknowledged edit, same as split/indent, so a fold that has 703 + // rendered survives a kill. 704 + Outline::new(self.storage.doc()) 705 + .set_folded(id, folded) 706 + .expect("set fold flag"); 707 + self.storage.persist_update().expect("persist fold toggle"); 694 708 self.refresh_rows(); 695 709 cx.notify(); 696 710 } ··· 2302 2316 ) 2303 2317 .unwrap(); 2304 2318 storage.persist_update().unwrap(); 2319 + } 2320 + 2321 + /// Collect every block whose document fold flag is set (spec: 2322 + /// outline-editor/"Fold state persists across relaunch"). Fully derivable 2323 + /// from the document, so the returned cache never holds anything the doc 2324 + /// can't rebuild. 2325 + fn load_folded(outline: &Outline) -> HashSet<TreeID> { 2326 + let mut folded = HashSet::new(); 2327 + let mut stack = outline.children(None); 2328 + while let Some(id) = stack.pop() { 2329 + if outline.folded(id).unwrap_or(false) { 2330 + folded.insert(id); 2331 + } 2332 + stack.extend(outline.children(Some(id))); 2333 + } 2334 + folded 2305 2335 } 2306 2336 2307 2337 /// Flatten the outline tree into depth-first visible rows, skipping the
+67
crates/trawler/src/ui_tests.rs
··· 362 362 }); 363 363 } 364 364 365 + #[gpui::test] 366 + fn fold_state_survives_relaunch(cx: &mut TestAppContext) { 367 + // Manual open_app so the second launch reuses the dir without 368 + // reseeding and without re-registering actions/keymap. 369 + crate::set_scroll_animation_ms(0); 370 + crate::editor::set_caret_animation_ms(0); 371 + crate::set_thread_animation_ms(0); 372 + let dir = fixture_dir("fold-persist"); 373 + cx.update(|cx| { 374 + crate::editor::init(cx); 375 + crate::init_keymap(cx); 376 + }); 377 + 378 + let (deep_work, child) = { 379 + let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir.clone(), w, c)); 380 + let deep_work = block_by_content(&app, cx, "Deep work"); 381 + let child = block_by_content(&app, cx, "Outlined the fixture graph #project"); 382 + focus_block(&app, cx, deep_work); 383 + cx.simulate_keystrokes("ctrl-shift-f"); 384 + app.update(cx, |app, _cx| assert!(app.folded.contains(&deep_work))); 385 + 386 + // Actually quit the first launch: remove its window, releasing the 387 + // storage and search-index handles (tantivy's writer lock would 388 + // otherwise block the reopen). 389 + cx.update(|window, _app| window.remove_window()); 390 + cx.run_until_parked(); 391 + (deep_work, child) 392 + }; 393 + // The scope above just dropped the last strong handle to the first 394 + // app entity; entities are only released during a flush, so flush 395 + // before reopening or the search-index writer lock is still held. 396 + cx.update(|_cx| {}); 397 + 398 + // "Relaunch": a fresh TrawlerApp over the same graph dir (spec: 399 + // outline-editor/"Folded block stays folded after relaunch"). 400 + let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir, w, c)); 401 + app.update(cx, |app, _cx| { 402 + assert!( 403 + app.folded.contains(&deep_work), 404 + "fold flag should be restored from the document" 405 + ); 406 + assert!(!app.rows.iter().any(|r| r.id == child)); 407 + assert!(app.rows.iter().any(|r| r.id == deep_work)); 408 + }); 409 + 410 + // Unfolding still works after the restore. 411 + focus_block(&app, cx, deep_work); 412 + cx.simulate_keystrokes("ctrl-shift-f"); 413 + app.update(cx, |app, _cx| { 414 + assert!(!app.folded.contains(&deep_work)); 415 + assert!(app.rows.iter().any(|r| r.id == child)); 416 + }); 417 + } 418 + 419 + #[gpui::test] 420 + fn graph_without_fold_flags_opens_fully_expanded(cx: &mut TestAppContext) { 421 + // The seeded fixture predates any fold flag — the absent-key default 422 + // (spec: outline-editor/"Graphs without fold state open expanded"). 423 + let (app, cx) = open_app("fold-absent", cx); 424 + app.update(cx, |app, _cx| { 425 + assert!( 426 + app.folded.is_empty(), 427 + "a graph with no fold flags should open fully expanded" 428 + ); 429 + }); 430 + } 431 + 365 432 // --- 2.6 focus movement ------------------------------------------------- 366 433 367 434 #[gpui::test]
+8 -8
openspec/changes/persist-collapsed-state/tasks.md
··· 1 1 ## 1. Fold flag in the document (trawler-core) 2 2 3 - - [ ] 1.1 Add `Outline::set_folded(id, bool)` and `Outline::folded(id) -> bool` in `outline.rs` — a `folded` meta key beside `block_type`/`content`, absent reads as false (design D1–D3) 4 - - [ ] 1.2 Unit tests: round-trip through close/reopen, absent-key default, flag absent from `properties()` output and property index (spec: "Fold flag is invisible to the property system") 5 - - [ ] 1.3 Confirm integrity gates and cross-release/golden-graph tests pass with folded blocks present — additive key, no format bump, no fixture regen (design D3; spec: "Pre-existing graphs are unaffected") 3 + - [x] 1.1 Add `Outline::set_folded(id, bool)` and `Outline::folded(id) -> bool` in `outline.rs` — a `folded` meta key beside `block_type`/`content`, absent reads as false (design D1–D3) 4 + - [x] 1.2 Unit tests: round-trip through close/reopen, absent-key default, flag absent from `properties()` output and property index (spec: "Fold flag is invisible to the property system") 5 + - [x] 1.3 Confirm integrity gates and cross-release/golden-graph tests pass with folded blocks present — additive key, no format bump, no fixture regen (design D3; spec: "Pre-existing graphs are unaffected"): fold toggles added to the gates' op generator and fold flags to `ObservableState`, so rebuild-equivalence and crash gates now cover the flag; golden graph opens unchanged 6 6 7 7 ## 2. App integration (trawler) 8 8 9 - - [ ] 2.1 On graph open, seed `self.folded` by walking the tree for blocks with `folded == true` (design D4) 10 - - [ ] 2.2 In `toggle_fold`, write the flag via `set_folded`, update the in-memory set, and call `persist_update` — same acknowledged-edit pattern as split/indent (design D4/D5) 9 + - [x] 2.1 On graph open, seed `self.folded` by walking the tree for blocks with `folded == true` (design D4) 10 + - [x] 2.2 In `toggle_fold`, write the flag via `set_folded`, update the in-memory set, and call `persist_update` — same acknowledged-edit pattern as split/indent (design D4/D5) 11 11 12 12 ## 3. UI tests and verification 13 13 14 - - [ ] 3.1 ui_tests: fold a block, drop and reopen the app model on the same graph dir, assert the block's rows render folded and unfold still works 15 - - [ ] 3.2 ui_tests: a graph with no fold flags renders fully expanded (existing fixtures double as the pre-existing-graph case) 16 - - [ ] 3.3 Workspace `cargo clippy` and full test suite green; dev-loop screenshot pass showing a fold surviving a real relaunch 14 + - [x] 3.1 ui_tests: fold a block, drop and reopen the app model on the same graph dir, assert the block's rows render folded and unfold still works 15 + - [x] 3.2 ui_tests: a graph with no fold flags renders fully expanded (existing fixtures double as the pre-existing-graph case) 16 + - [x] 3.3 Workspace `cargo clippy` and full test suite green; dev-loop screenshot pass showing a fold surviving a real relaunch (hard-killed the app after folding — the fold was restored on relaunch)