diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index e2bc3c6..0ae028a 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -78,7 +78,8 @@ actions!( ToggleFold, ToggleSidebar, GoHome, - GoToJournal + GoToJournal, + NewPage ] ); @@ -149,6 +150,7 @@ fn init_keymap(cx: &mut App) { KeyBinding::new("ctrl-shift-c", ToggleCalendar, Some(APP_CONTEXT)), KeyBinding::new("ctrl-shift-b", ToggleSidebar, Some(APP_CONTEXT)), KeyBinding::new("ctrl-h", GoHome, Some(APP_CONTEXT)), + KeyBinding::new("ctrl-n", NewPage, Some(APP_CONTEXT)), ]); } @@ -822,6 +824,81 @@ impl TrawlerApp { self.toggle_fold(block, cx); } + /// Sweep the page being navigated away from if it is a completely + /// untouched standalone creation (spec: page-creation/"Abandoned new + /// pages are swept"): a root page with an empty heading, only empty + /// childless children, and no inbound references (to it or its + /// children) is deleted, so an abandoned ctrl-n leaves no litter. + /// Deliberately conservative — any name, content, or reference keeps + /// the page — and only the departed page is ever examined (design + /// D3); pre-existing empty pages elsewhere are never touched. The + /// designated home is never swept, and navigating *within* the page + /// (zooming into its child) never sweeps it out from under the + /// destination. Callers run this after committing the departing + /// editor (a just-typed heading counts as a name) and before the view + /// changes; returns whether a sweep happened so history bookkeeping + /// can skip recording the now-dead view. + fn sweep_abandoned_page(&mut self, next_view: &View) -> bool { + let View::Node(id) = &self.view else { + return false; + }; + if next_view == &self.view { + return false; + } + let Some(root) = id.as_tree_id() else { + return false; + }; + let outline = Outline::new(self.storage.doc()); + if !outline.exists(root) || outline.parent(root).is_some() { + return false; + } + if !outline.content(root).unwrap_or_default().is_empty() { + return false; + } + let children = outline.children(Some(root)); + for &child in &children { + if !outline.content(child).unwrap_or_default().is_empty() + || !outline.children(Some(child)).is_empty() + { + return false; + } + } + if let View::Node(next) = next_view { + if let Some(mut cursor) = next.as_tree_id() { + loop { + if cursor == root { + return false; + } + match outline.parent(cursor) { + Some(parent) => cursor = parent, + None => break, + } + } + } + } + if trawler_core::settings::Settings::new(self.storage.doc()).home() + == Some(NodeId::tree(root)) + { + return false; + } + let index = GraphIndex::rebuild(self.storage.doc()); + if !index.backlinks_of(&NodeId::tree(root)).is_empty() { + return false; + } + for &child in &children { + if !index.backlinks_of(&NodeId::tree(child)).is_empty() { + return false; + } + } + outline.delete_block(root).expect("sweep abandoned page"); + self.storage.persist_update().expect("persist sweep"); + for &child in &children { + let _ = self.search.remove_block(child); + } + let _ = self.search.remove_block(root); + true + } + /// Navigate to a new view, recording history (spec: "Navigation /// history") and focusing `focus_hint` if given, else the first block /// of the new view. @@ -836,9 +913,14 @@ impl TrawlerApp { // Any navigation closes an open picker — most visibly the Settings // home dropdown, which must not linger once its screen is gone. self.quick_open = None; + let swept = self.sweep_abandoned_page(&view); if view != self.view { - let left_scroll = self.list_state.logical_scroll_top(); - self.history_back.push((self.view.clone(), left_scroll)); + // A swept view is gone — recording it would leave a dead + // history entry pointing at a deleted page. + if !swept { + let left_scroll = self.list_state.logical_scroll_top(); + self.history_back.push((self.view.clone(), left_scroll)); + } self.history_forward.clear(); } self.view = view; @@ -1163,8 +1245,13 @@ impl TrawlerApp { return; }; self.commit_editor(cx); - let left_scroll = self.list_state.logical_scroll_top(); - self.history_forward.push((self.view.clone(), left_scroll)); + // Same sweep-and-skip as `navigate_to`: an abandoned page left via + // back must not survive as a dead forward entry. + let swept = self.sweep_abandoned_page(&view); + if !swept { + let left_scroll = self.list_state.logical_scroll_top(); + self.history_forward.push((self.view.clone(), left_scroll)); + } self.view = view; self.refresh_view_data(); // The saved offset must win on back/forward: the freshly attached @@ -1194,8 +1281,12 @@ impl TrawlerApp { return; }; self.commit_editor(cx); - let left_scroll = self.list_state.logical_scroll_top(); - self.history_back.push((self.view.clone(), left_scroll)); + // Same sweep-and-skip as `navigate_to`/`navigate_back`. + let swept = self.sweep_abandoned_page(&view); + if !swept { + let left_scroll = self.list_state.logical_scroll_top(); + self.history_back.push((self.view.clone(), left_scroll)); + } self.view = view; self.refresh_view_data(); // Same reveal-suppression rationale as `navigate_back`. @@ -1376,6 +1467,30 @@ impl TrawlerApp { self.navigate_to(View::Journal, None, window, cx); } + /// Ctrl+N (spec: page-creation/"Standalone page creation"): create an + /// empty root page — no parental backlink, nothing referencing it — + /// navigate to it, and focus its heading so typing names it in place + /// (the heading IS the editable root block, ui-polish design D6). + /// Enter from the heading then drops into the empty first block via + /// the existing page-root split path (`split_focused_block`). + fn new_page(&mut self, _: &NewPage, window: &mut Window, cx: &mut Context) { + let page = { + let outline = Outline::new(self.storage.doc()); + let index = outline.children(None).len(); + let page = outline + .create_block(None, Position::Index(index), "") + .expect("create standalone page"); + outline + .create_block(Some(page), Position::Index(0), "") + .expect("create empty first block"); + page + }; + self.storage.persist_update().expect("persist new page"); + // Empty content — nothing to index yet; the heading commit will + // upsert it like any other edit. + self.navigate_to(View::Node(NodeId::tree(page)), Some(page), window, cx); + } + /// The live query text with a caret bar after it — the visible signal /// that a picker is type-to-refine. An empty query shows a muted /// placeholder behind the caret instead. @@ -2501,7 +2616,14 @@ impl TrawlerApp { } /// Shift+Tab: outdent the focused block to become its parent's next - /// sibling. No-op if the block is already at the root. + /// sibling. No-op if the block is already at the root — and bounded + /// by the current view (spec: outline-editor/"Keyboard-complete + /// outline manipulation" as revised by openspec change + /// create-standalone-page): a block whose parent is the view's + /// represented node (a page root, or the zoom root when zoomed) stays + /// put, since outdenting past it would move the block out of the + /// visible outline — and, at the top level, silently mint a new root + /// page. fn outdent_focused(&mut self, cx: &mut Context) { let Some(block) = self.editor.as_ref().map(|e| e.block) else { return; @@ -2510,6 +2632,13 @@ impl TrawlerApp { let Some(parent) = outline.parent(block) else { return; }; + let parent_is_view_root = match &self.view { + View::Node(id) => id.as_tree_id() == Some(parent), + _ => false, + }; + if parent_is_view_root || outline.parent(parent).is_none() { + return; + } let grandparent = outline.parent(parent); outline .move_subtree(block, grandparent, Position::After(parent)) @@ -4335,6 +4464,7 @@ impl Render for TrawlerApp { .on_action(cx.listener(Self::toggle_sidebar)) .on_action(cx.listener(Self::go_home)) .on_action(cx.listener(Self::go_to_journal)) + .on_action(cx.listener(Self::new_page)) .on_action(cx.listener(Self::create_page_for_view)) .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index bed513c..b1f7cf9 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -1562,3 +1562,235 @@ async fn settings_participates_in_history(cx: &mut gpui::TestAppContext) { cx.run_until_parked(); assert_eq!(current_view(&app, cx), View::Journal); } + +// --- standalone pages (openspec change create-standalone-page) --------------- + +/// ctrl-n creates a root page and naming happens in the heading, no +/// dialog; Enter drops into the empty first block (spec: +/// page-creation/"Standalone page creation"). +#[gpui::test] +async fn ctrl_n_creates_and_names_page_in_place(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-name", cx); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + assert_eq!( + focused_editor_text(&app, cx), + "", + "the fresh heading is focused and empty" + ); + + cx.simulate_input("reading-notes"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + + let page = block_by_content(&app, cx, "reading-notes"); + assert_eq!(current_view(&app, cx), View::Node(NodeId::tree(page))); + let focused = focused_block(&app, cx); + assert_eq!( + parent_of(&app, cx, focused), + Some(page), + "Enter moves from the heading into the page's first block" + ); +} + +/// ctrl-n works from an unfocused designated-home view — the flow the +/// home-document change deliberately leaves unfocused (spec scenario: +/// "Creation from an unfocused home"). +#[gpui::test] +async fn ctrl_n_works_from_unfocused_home(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("new-page-from-home", "trawler-design"); + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + app.update(cx, |app, _cx| assert!(app.editor.is_none())); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + app.update(cx, |app, _cx| { + assert!(app.editor.is_some(), "new page's heading takes focus"); + }); +} + +/// An untouched ctrl-n page is deleted when navigation leaves it (spec: +/// page-creation/"Abandoned new pages are swept"). +#[gpui::test] +async fn abandoned_new_page_is_swept(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-sweep", cx); + cx.run_until_parked(); + let pages_before = children_of(&app, cx, None).len(); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + assert_eq!(children_of(&app, cx, None).len(), pages_before + 1); + + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); + assert_eq!( + children_of(&app, cx, None).len(), + pages_before, + "abandoned page and its empty child are gone" + ); +} + +/// Any typed name keeps the page, it stays findable, and forward +/// navigation can return to it (the sweep must not leave dead history). +#[gpui::test] +async fn named_new_page_survives_navigation(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-survives", cx); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + cx.simulate_input("keeper"); + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + + let page = block_by_content(&app, cx, "keeper"); + cx.simulate_keystrokes("alt-right"); + cx.run_until_parked(); + assert_eq!( + current_view(&app, cx), + View::Node(NodeId::tree(page)), + "forward returns to the kept page" + ); +} + +/// A still-empty page that gained an inbound reference survives the +/// sweep (spec scenario: "Referenced page survives"). +#[gpui::test] +async fn referenced_empty_page_survives_sweep(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-referenced", cx); + cx.run_until_parked(); + let pages_before = children_of(&app, cx, None).len(); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + // Reference the empty page by id from an existing block. + let referrer = block_by_content(&app, cx, "Deep work"); + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + let page = app.view.clone(); + let View::Node(id) = page else { unreachable!() }; + let page = id.as_tree_id().expect("new page is a tree node"); + outline + .set_content(referrer, &format!("Deep work (({page}))")) + .expect("add block reference"); + app.storage.persist_update().expect("persist reference"); + }); + + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + assert_eq!( + children_of(&app, cx, None).len(), + pages_before + 1, + "a referenced page must never be swept" + ); +} + +/// Pre-existing empty pages elsewhere are not the sweep's business — only +/// the page being navigated away from is examined (design D3). +#[gpui::test] +async fn sweep_ignores_unrelated_empty_pages(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-unrelated", cx); + cx.run_until_parked(); + // Plant a pre-existing empty page directly in storage. + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + let index = outline.children(None).len(); + outline + .create_block(None, trawler_core::outline::Position::Index(index), "") + .expect("plant empty page"); + app.storage.persist_update().expect("persist plant"); + }); + let pages_before = children_of(&app, cx, None).len(); + + open_page(cx, "trawler-design"); + cx.run_until_parked(); + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + + assert_eq!( + children_of(&app, cx, None).len(), + pages_before, + "navigating around must not delete unrelated empty pages" + ); +} + +/// The search index stays consistent through the create/sweep lifecycle: +/// a named page is findable via the ordinary commit path, and after an +/// abandoned page is swept the incrementally-maintained index answers +/// identically to a from-scratch rebuild (no dangling entries). +#[gpui::test] +async fn search_index_consistent_through_create_and_sweep(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("new-page-search-index", cx); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + cx.simulate_input("searchable-page"); + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + + // Abandon a second page so the sweep's remove path runs too. + cx.simulate_keystrokes("ctrl-n"); + cx.run_until_parked(); + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + + app.update(cx, |app, _cx| { + let incremental = app + .search + .search("searchable-page", 10) + .expect("search incremental index"); + assert!(!incremental.is_empty(), "named page is indexed"); + app.search + .rebuild(app.storage.doc()) + .expect("rebuild from doc"); + let rebuilt = app + .search + .search("searchable-page", 10) + .expect("search rebuilt index"); + assert_eq!( + incremental.len(), + rebuilt.len(), + "incremental index must match a from-scratch rebuild" + ); + }); +} + +/// Shift+Tab never moves a block out of the current view (spec: +/// outline-editor/"Keyboard-complete outline manipulation" as revised): +/// a depth-1 block stays under its page root, while deeper blocks still +/// outdent normally. +#[gpui::test] +async fn outdent_is_bounded_by_the_view(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("outdent-bounded", cx); + cx.run_until_parked(); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + // Depth 2 → depth 1 still works (control). + let deep = block_by_content(&app, cx, "Ship fixture graphs #project"); + let mid = parent_of(&app, cx, deep).expect("has a parent"); + focus_block(&app, cx, deep); + cx.simulate_keystrokes("shift-tab"); + cx.run_until_parked(); + let page = parent_of(&app, cx, deep).expect("still parented"); + assert_ne!(parent_of(&app, cx, deep), Some(mid), "deep block outdented"); + + // Depth 1: parent is the page root — shift-tab must be a no-op. + cx.simulate_keystrokes("shift-tab"); + cx.run_until_parked(); + assert_eq!( + parent_of(&app, cx, deep), + Some(page), + "a block never outdents past the view's page root" + ); + let pages: Vec<_> = children_of(&app, cx, None); + assert!( + !pages.contains(&deep), + "the block must not become a root page" + ); +} diff --git a/openspec/changes/create-standalone-page/proposal.md b/openspec/changes/create-standalone-page/proposal.md index 0673b89..d9eca70 100644 --- a/openspec/changes/create-standalone-page/proposal.md +++ b/openspec/changes/create-standalone-page/proposal.md @@ -9,6 +9,8 @@ There is no way to create a page from nothing: `CreatePage` (ctrl-enter) only ma - New `NewPage` action bound to `ctrl-n`: creates an empty root page, navigates to it, and focuses its heading — the heading is the editable root block (ui-polish design D6), so typing names the page in place. No name dialog. - **Sweep on navigate-away**: leaving a page that is still empty — no name, no children with content, and unreferenced — deletes it, so abandoned `ctrl-n` presses leave no litter (mirrors the journal's no-empty-page-litter behavior). +- **Outdent is bounded by the view** (added during implementation review): Shift+Tab on a block whose parent is the view's represented node — a page root, or the zoom root — is a no-op. Previously it silently promoted the block out of the visible outline (at the top level, minting a new root page), which standalone documents made easy to trigger. + ## Capabilities ### New Capabilities @@ -17,7 +19,7 @@ There is no way to create a page from nothing: `CreatePage` (ctrl-enter) only ma ### Modified Capabilities -None — `CreatePage`'s existing Tag/Date materialization is untouched, and no existing requirement changes behavior. +- `outline-editor`: "Keyboard-complete outline manipulation" gains the outdent floor (a block never outdents past the view's represented node). ## Impact diff --git a/openspec/changes/create-standalone-page/specs/outline-editor/spec.md b/openspec/changes/create-standalone-page/specs/outline-editor/spec.md new file mode 100644 index 0000000..24d951c --- /dev/null +++ b/openspec/changes/create-standalone-page/specs/outline-editor/spec.md @@ -0,0 +1,22 @@ +# outline-editor Specification (delta) + +## MODIFIED Requirements + +### Requirement: Keyboard-complete outline manipulation +All outline operations SHALL be executable without the mouse: create sibling (Enter), insert a newline within the block (Shift+Enter), split block at cursor, indent/outdent (Tab/Shift+Tab), move block up/down among siblings, delete/merge with previous (Backspace at start), and fold/unfold subtree. Backspace at the start of a childless block SHALL merge it into the *visually previous* row — a previous sibling's deepest visible descendant, or the parent when the block is a first child — with the exception that merging into a page root is permitted only when the block is empty (pure deletion), never appending content into a page title. Outdent SHALL be bounded by the current view: a block whose parent is the view's represented node — a page root, or the zoomed block when zoomed — SHALL NOT outdent further, since doing so would move it out of the visible outline (and, at the top level, silently mint a new root page). + +#### Scenario: Indent under previous sibling +- **WHEN** the cursor is in a block and the user presses Tab +- **THEN** the block (with its subtree) becomes the last child of its previous sibling, and the cursor position within the text is preserved + +#### Scenario: Newline within a block vs. new block +- **WHEN** the user presses Shift+Enter mid-block, types a second paragraph, then presses Enter +- **THEN** the block contains both paragraphs, and a new empty sibling block is created and focused + +#### Scenario: Fold hides descendants +- **WHEN** the user folds a block with descendants +- **THEN** descendants are hidden, a fold indicator is shown, and keyboard navigation skips the hidden blocks + +#### Scenario: Outdent stops at the view's top level +- **WHEN** the cursor is in a block whose parent is the viewed page's root (or the zoom root, when zoomed) and the user presses Shift+Tab +- **THEN** nothing changes — the block remains where it was, still visible in the current view diff --git a/openspec/changes/create-standalone-page/tasks.md b/openspec/changes/create-standalone-page/tasks.md index fc616d6..03d0c74 100644 --- a/openspec/changes/create-standalone-page/tasks.md +++ b/openspec/changes/create-standalone-page/tasks.md @@ -1,17 +1,21 @@ -# Tasks: create-standalone-page +# Tasks: create-standalone-page + +## 1. Creation flow + +- [x] 1.1 Add `NewPage` action bound to `ctrl-n`; handler creates an empty root page with one empty child (`Outline::create_block(None, …)`), persists, and navigates to `View::Node(root)`. +- [x] 1.2 Focus the new page's heading (root block editor) on arrival; verify Enter from the empty heading moves into the first block (existing D6 heading flow). +- [x] 1.3 UI tests: `ctrl-n` then typing names the page in place; Enter lands in the first block; works from journal, from a page, and from an unfocused home view. + +## 2. Sweep + +- [x] 2.1 Implement the sweep check in the navigation path: on leaving `View::Node(root)` where the root has an empty heading, no non-empty children, and no inbound references, delete the page (and its empty child) and persist. +- [x] 2.2 UI tests: abandoned `ctrl-n` page is gone after navigate-back and absent from quick-open; named page survives; still-empty page with an inbound reference survives; pre-existing empty pages elsewhere in the graph are untouched. + +## 3. Verification + +- [x] 3.1 Search-index and backlinks stay consistent through create + sweep (no dangling index entries); cover with a storage-level test. +- [x] 3.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop pass exercising the create/name/sweep flow. -## 1. Creation flow +## 4. Outdent bound (added during implementation review) -- [ ] 1.1 Add `NewPage` action bound to `ctrl-n`; handler creates an empty root page with one empty child (`Outline::create_block(None, …)`), persists, and navigates to `View::Node(root)`. -- [ ] 1.2 Focus the new page's heading (root block editor) on arrival; verify Enter from the empty heading moves into the first block (existing D6 heading flow). -- [ ] 1.3 UI tests: `ctrl-n` then typing names the page in place; Enter lands in the first block; works from journal, from a page, and from an unfocused home view. - -## 2. Sweep - -- [ ] 2.1 Implement the sweep check in the navigation path: on leaving `View::Node(root)` where the root has an empty heading, no non-empty children, and no inbound references, delete the page (and its empty child) and persist. -- [ ] 2.2 UI tests: abandoned `ctrl-n` page is gone after navigate-back and absent from quick-open; named page survives; still-empty page with an inbound reference survives; pre-existing empty pages elsewhere in the graph are untouched. - -## 3. Verification - -- [ ] 3.1 Search-index and backlinks stay consistent through create + sweep (no dangling index entries); cover with a storage-level test. -- [ ] 3.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop pass exercising the create/name/sweep flow. +- [x] 4.1 Bound Shift+Tab by the current view: no-op when the block's parent is the view's represented node (page root or zoom root); spec delta on outline-editor's "Keyboard-complete outline manipulation" plus UI test.