···9090 71..=74 => self.remove_prop(outline),
9191 75..=84 => self.move_subtree(outline),
9292 85..=91 => self.reorder(outline),
9393+ 92..=94 => self.toggle_fold(outline),
9394 _ => self.delete(outline),
9495 }
9596 }
···301302 }
302303 }
303304305305+ /// Fold state is document content (openspec change
306306+ /// persist-collapsed-state), so the gates cover it like any other
307307+ /// mutation. No index events: the flag is invisible to the
308308+ /// property/backlink/search indexes by design.
309309+ fn toggle_fold(&mut self, outline: &Outline) -> StepOutcome {
310310+ let id = self.pick();
311311+ let folded = !outline.folded(id).expect("read fold flag");
312312+ outline.set_folded(id, folded).expect("set_folded");
313313+ StepOutcome {
314314+ events: vec![],
315315+ content_changed: vec![],
316316+ deleted: vec![],
317317+ description: format!("fold {id} -> {folded}"),
318318+ }
319319+ }
320320+304321 fn delete(&mut self, outline: &Outline) -> StepOutcome {
305322 let id = self.pick();
306323 let parent = outline.parent(id);
···340357/// Everything the graph *answers*, flattened into an ordered key → value
341358/// map so two states can be compared and the first divergence named. The
342359/// keys cover: root order and page names, per-parent child order, every
343343-/// block's content, backlinks per node, tag membership, per-block property
344344-/// values, and search hits for a deterministic content-derived term sample.
360360+/// block's content and fold flag, backlinks per node, tag membership,
361361+/// per-block property values, and search hits for a deterministic
362362+/// content-derived term sample.
345363/// Deliberately not a struct-equality check — representation may change,
346364/// answers may not (spec: "observable identity").
347365#[derive(Debug, Clone, PartialEq, Eq)]
···398416 }
399417 }
400418 entries.insert(format!("content:{id}"), content);
419419+ }
420420+ // Fold flags are document content (openspec change
421421+ // persist-collapsed-state). Folded-only entries, so captures
422422+ // of pre-flag graphs compare clean against post-flag ones.
423423+ if outline.folded(id).unwrap_or(false) {
424424+ entries.insert(format!("folded:{id}"), "true".to_string());
401425 }
402426 }
403427
···187187 }
188188 }
189189190190+ /// Set or clear a block's fold flag (spec: block-graph/"Fold state is
191191+ /// document content"). A plain `folded` meta key beside `block_type` —
192192+ /// deliberately not a `properties` entry, so it never surfaces in
193193+ /// property queries or indexes, and last-write-wins merge (the meta
194194+ /// map's semantics) is right for a boolean toggle.
195195+ pub fn set_folded(&self, id: TreeID, folded: bool) -> LoroResult<()> {
196196+ let meta = self.tree().get_meta(id)?;
197197+ meta.insert("folded", folded)?;
198198+ Ok(())
199199+ }
200200+201201+ /// A block's fold flag. Absent — every block written before the flag
202202+ /// existed — reads as expanded, so old graphs open unchanged.
203203+ pub fn folded(&self, id: TreeID) -> LoroResult<bool> {
204204+ let meta = self.tree().get_meta(id)?;
205205+ Ok(matches!(
206206+ meta.get("folded"),
207207+ Some(loro::ValueOrContainer::Value(loro::LoroValue::Bool(true)))
208208+ ))
209209+ }
210210+190211 /// Set (or overwrite) a typed property on a block.
191212 pub fn set_property(
192213 &self,
···373394374395 // Removing a key that isn't set must not error.
375396 outline.remove_property(block, "never-set").unwrap();
397397+ }
398398+399399+ #[test]
400400+ fn fold_flag_round_trips_and_defaults_to_expanded() {
401401+ let doc = doc_with_tree();
402402+ let outline = Outline::new(&doc);
403403+ let page = outline.create_block(None, Position::Index(0), "").unwrap();
404404+ let a = outline
405405+ .create_block(Some(page), Position::Index(0), "folded")
406406+ .unwrap();
407407+ let b = outline
408408+ .create_block(Some(page), Position::Index(1), "expanded")
409409+ .unwrap();
410410+411411+ // Absent key (every pre-flag block) reads as expanded.
412412+ assert!(!outline.folded(a).unwrap());
413413+414414+ outline.set_folded(a, true).unwrap();
415415+ assert!(outline.folded(a).unwrap());
416416+417417+ // Survives export/import — the same path storage snapshots take.
418418+ let bytes = doc.export(loro::ExportMode::Snapshot).unwrap();
419419+ let reopened = LoroDoc::new();
420420+ reopened.import(&bytes).unwrap();
421421+ let ro = Outline::new(&reopened);
422422+ assert!(ro.folded(a).unwrap());
423423+ assert!(!ro.folded(b).unwrap());
424424+425425+ outline.set_folded(a, false).unwrap();
426426+ assert!(!outline.folded(a).unwrap());
427427+ }
428428+429429+ #[test]
430430+ fn fold_flag_is_invisible_to_the_property_system() {
431431+ let doc = doc_with_tree();
432432+ let outline = Outline::new(&doc);
433433+ let page = outline.create_block(None, Position::Index(0), "").unwrap();
434434+ let block = outline
435435+ .create_block(Some(page), Position::Index(0), "bulky")
436436+ .unwrap();
437437+438438+ outline.set_folded(block, true).unwrap();
439439+440440+ // The flag must not leak into `properties()` — that is what the
441441+ // property index and queries read (spec: block-graph/"Fold flag is
442442+ // invisible to the property system").
443443+ assert!(outline.properties(block).unwrap().is_empty());
376444 }
377445378446 #[test]
···450450 .expect("open or create search index"),
451451 );
452452453453+ // Fold flags live in the document (spec: outline-editor/"Fold
454454+ // state persists across relaunch"); the in-memory set is a derived
455455+ // render cache seeded here and kept in step by `toggle_fold`.
456456+ let folded = load_folded(&Outline::new(storage.doc()));
457457+453458 let mut app = Self {
454459 graph_dir,
455460 storage,
···461466 backlinks: Vec::new(),
462467 breadcrumb: Vec::new(),
463468 editor: None,
464464- folded: HashSet::new(),
469469+ folded,
465470 quick_open: None,
466471 quick_open_focus: cx.focus_handle(),
467472 root_focus: cx.focus_handle(),
···688693 }
689694690695 fn toggle_fold(&mut self, id: TreeID, cx: &mut Context<Self>) {
691691- if !self.folded.remove(&id) {
696696+ let folded = !self.folded.remove(&id);
697697+ if folded {
692698 self.folded.insert(id);
693699 }
700700+ // Fold state is document content (spec: block-graph/"Fold state is
701701+ // document content"): write the flag and persist as an
702702+ // acknowledged edit, same as split/indent, so a fold that has
703703+ // rendered survives a kill.
704704+ Outline::new(self.storage.doc())
705705+ .set_folded(id, folded)
706706+ .expect("set fold flag");
707707+ self.storage.persist_update().expect("persist fold toggle");
694708 self.refresh_rows();
695709 cx.notify();
696710 }
···23022316 )
23032317 .unwrap();
23042318 storage.persist_update().unwrap();
23192319+}
23202320+23212321+/// Collect every block whose document fold flag is set (spec:
23222322+/// outline-editor/"Fold state persists across relaunch"). Fully derivable
23232323+/// from the document, so the returned cache never holds anything the doc
23242324+/// can't rebuild.
23252325+fn load_folded(outline: &Outline) -> HashSet<TreeID> {
23262326+ let mut folded = HashSet::new();
23272327+ let mut stack = outline.children(None);
23282328+ while let Some(id) = stack.pop() {
23292329+ if outline.folded(id).unwrap_or(false) {
23302330+ folded.insert(id);
23312331+ }
23322332+ stack.extend(outline.children(Some(id)));
23332333+ }
23342334+ folded
23052335}
2306233623072337/// Flatten the outline tree into depth-first visible rows, skipping the
···362362 });
363363}
364364365365+#[gpui::test]
366366+fn fold_state_survives_relaunch(cx: &mut TestAppContext) {
367367+ // Manual open_app so the second launch reuses the dir without
368368+ // reseeding and without re-registering actions/keymap.
369369+ crate::set_scroll_animation_ms(0);
370370+ crate::editor::set_caret_animation_ms(0);
371371+ crate::set_thread_animation_ms(0);
372372+ let dir = fixture_dir("fold-persist");
373373+ cx.update(|cx| {
374374+ crate::editor::init(cx);
375375+ crate::init_keymap(cx);
376376+ });
377377+378378+ let (deep_work, child) = {
379379+ let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir.clone(), w, c));
380380+ let deep_work = block_by_content(&app, cx, "Deep work");
381381+ let child = block_by_content(&app, cx, "Outlined the fixture graph #project");
382382+ focus_block(&app, cx, deep_work);
383383+ cx.simulate_keystrokes("ctrl-shift-f");
384384+ app.update(cx, |app, _cx| assert!(app.folded.contains(&deep_work)));
385385+386386+ // Actually quit the first launch: remove its window, releasing the
387387+ // storage and search-index handles (tantivy's writer lock would
388388+ // otherwise block the reopen).
389389+ cx.update(|window, _app| window.remove_window());
390390+ cx.run_until_parked();
391391+ (deep_work, child)
392392+ };
393393+ // The scope above just dropped the last strong handle to the first
394394+ // app entity; entities are only released during a flush, so flush
395395+ // before reopening or the search-index writer lock is still held.
396396+ cx.update(|_cx| {});
397397+398398+ // "Relaunch": a fresh TrawlerApp over the same graph dir (spec:
399399+ // outline-editor/"Folded block stays folded after relaunch").
400400+ let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir, w, c));
401401+ app.update(cx, |app, _cx| {
402402+ assert!(
403403+ app.folded.contains(&deep_work),
404404+ "fold flag should be restored from the document"
405405+ );
406406+ assert!(!app.rows.iter().any(|r| r.id == child));
407407+ assert!(app.rows.iter().any(|r| r.id == deep_work));
408408+ });
409409+410410+ // Unfolding still works after the restore.
411411+ focus_block(&app, cx, deep_work);
412412+ cx.simulate_keystrokes("ctrl-shift-f");
413413+ app.update(cx, |app, _cx| {
414414+ assert!(!app.folded.contains(&deep_work));
415415+ assert!(app.rows.iter().any(|r| r.id == child));
416416+ });
417417+}
418418+419419+#[gpui::test]
420420+fn graph_without_fold_flags_opens_fully_expanded(cx: &mut TestAppContext) {
421421+ // The seeded fixture predates any fold flag — the absent-key default
422422+ // (spec: outline-editor/"Graphs without fold state open expanded").
423423+ let (app, cx) = open_app("fold-absent", cx);
424424+ app.update(cx, |app, _cx| {
425425+ assert!(
426426+ app.folded.is_empty(),
427427+ "a graph with no fold flags should open fully expanded"
428428+ );
429429+ });
430430+}
431431+365432// --- 2.6 focus movement -------------------------------------------------
366433367434#[gpui::test]
···11## 1. Fold flag in the document (trawler-core)
2233-- [ ] 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)
44-- [ ] 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")
55-- [ ] 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")
33+- [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)
44+- [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")
55+- [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
6677## 2. App integration (trawler)
8899-- [ ] 2.1 On graph open, seed `self.folded` by walking the tree for blocks with `folded == true` (design D4)
1010-- [ ] 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)
99+- [x] 2.1 On graph open, seed `self.folded` by walking the tree for blocks with `folded == true` (design D4)
1010+- [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)
11111212## 3. UI tests and verification
13131414-- [ ] 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
1515-- [ ] 3.2 ui_tests: a graph with no fold flags renders fully expanded (existing fixtures double as the pre-existing-graph case)
1616-- [ ] 3.3 Workspace `cargo clippy` and full test suite green; dev-loop screenshot pass showing a fold surviving a real relaunch
1414+- [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
1515+- [x] 3.2 ui_tests: a graph with no fold flags renders fully expanded (existing fixtures double as the pre-existing-graph case)
1616+- [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)