From 5888d143fda08d422afdb82c90b222d0c45ed7c0 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 2 Oct 2026 11:25:57 -0400 Subject: [PATCH] Branch a branch out into a document of its own (stage 9b) o (the user's pick), :branchout [file] or the right-click menu moves the cursor's branch into a new document, next to this one and named after the branch's title (-2 when that's taken), or at the path given. Its top node becomes the root, in the middle of the canvas, and the nodes keep their ids, so the links among them, the small and capital marks on them and the jump lists still find them. A portal to the new document takes the branch's place (same spot in the tree and on the canvas, same branch line) as one undo step, with a new EditOp::BranchOut; u brings the branch back while the new file stays on disk, as after :new. Then the pane goes through the portal (the user's pick), so Ctrl-o comes back and the breadcrumb starts with the document it came from. Portals inside the branch move along (the user's pick), their paths made from the new file's folder, and their documents' backlinks follow. Links between the branch and the rest can't follow, since links stay inside a document, and the message counts them. The root and portals themselves can't branch out. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 29 ++++++- README.md | 1 + src/app/action.rs | 4 + src/app/branch_out.rs | 158 ++++++++++++++++++++++++++++++++++++ src/app/calls.rs | 3 +- src/app/commands.rs | 1 + src/app/files.rs | 9 +- src/app/jumps.rs | 10 +++ src/app/mod.rs | 1 + src/app/popup.rs | 1 + src/app/tests.rs | 3 +- src/app/tests/branch_out.rs | 85 +++++++++++++++++++ src/app/update.rs | 1 + src/command/parse.rs | 6 +- src/command/registry.rs | 8 +- src/input/bindings.rs | 1 + src/input/command.rs | 3 + src/model/op.rs | 21 ++++- src/storage/memory.rs | 11 ++- 19 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 src/app/branch_out.rs create mode 100644 src/app/tests/branch_out.rs diff --git a/DESIGN.md b/DESIGN.md index 92aaf5b..3be74c8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -276,6 +276,26 @@ lines. `:border`, `:style`, `:tag` and `:fold`, setting every selected node as one undo step, the way the pickers do. +**Branching out** (settled in part 9b) +- `o` (the user's pick), `:branchout [file]` or the right-click + menu moves the cursor's branch into a document of its own: next to this + one, named after the branch's title (`-2` when that's taken), or at the + path given. Its top node becomes the root, in the middle of the canvas, + and the nodes keep their ids, so the links among them, the marks on them + (small and capital) and the jump lists still find them. +- A portal to the new document takes the branch's place (the same spot in + the tree and on the canvas, the same branch line), as one undo step: `u` + brings the branch back, and the new file stays on disk, as after `:new`. + The pane then goes to the new document, on its root, as through the + portal (the user's pick): `Ctrl-o` comes back, and the breadcrumb starts + with the document it came from. +- Portals inside the branch move along (the user's pick), still leading + where they did from the new file's folder, and their documents' + backlinks follow. Links between the branch and the rest of the document + can't follow (links stay inside a document): the message counts them. + The root can't branch out (it's the whole document), and neither can a + portal (its document is one of its own already). + **Views, panes and popups** (settled in stage 7) - The Note view is made of parts: the title, the tags, the body, each link (out, then in) and each child. `j` and `k` step through them, with a bar @@ -505,7 +525,7 @@ text). | **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype | | **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo | | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal | -| **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | +| **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | | **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) | | **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above | | **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after | @@ -709,13 +729,14 @@ cumulus/ "+), yank (nodes into registers and back), visual (selections), repeat (`.`), search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), connect (connect mode), portals (what portals show, going through them, - `:portal`, backlinks, renames), panes (the split tree), popup (help, menu, - pickers, card, questions) + `:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split + tree), popup (help, menu, pickers, card, questions) ``` The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, `app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`, -`app/tests/portals.rs` and `app/tests/arrange.rs`): they copy the sample +`app/tests/portals.rs`, `app/tests/arrange.rs` and `app/tests/branch_out.rs`): +they copy the sample documents to a temporary folder, type keys and click on a test terminal, and check the screen, the documents and the files. diff --git a/README.md b/README.md index 4a37860..27d8794 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document | | `gd` | follow a cross-link | | `p` | a portal to another document (`:portal file`, or a new document's title) | +| `o` | this branch out, into a document of its own, with a portal in its place (`:branchout`) | | Enter or `gf` `gu` | through a portal / up to the portals that lead here | | `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… | | `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) | diff --git a/src/app/action.rs b/src/app/action.rs index 1e26250..80f0ced 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -138,6 +138,10 @@ pub enum AppAction { /// `:portal`: a portal under the cursor's node, to the document at the /// path typed (a new document when nothing's there). NewPortal(String), + /// `:branchout` and `o`: the cursor's branch moves into a + /// document of its own (at the path typed, or next to this one), with a + /// portal to it in its place. + BranchOut(Option), /// `gu`: up to the portals that lead to this document; `list` /// (`:backlinks`) lists them even when there's just one. GoUp { diff --git a/src/app/branch_out.rs b/src/app/branch_out.rs new file mode 100644 index 0000000..8e11d76 --- /dev/null +++ b/src/app/branch_out.rs @@ -0,0 +1,158 @@ +//! Branching out (`:branchout`, `o`): the cursor's branch moves into +//! a document of its own, its top node the new root, and a portal to it +//! takes the branch's place. Then the pane goes there, as through the portal +//! (the user's pick in part 9b), so `Ctrl-o` comes back to it. +//! +//! The nodes keep their ids in the new document, so the links among them, +//! and the marks and jumps on them, still find them there. Portals inside +//! the branch move along (the user's pick), still leading where they did. +//! Links between the branch and the rest of the document can't follow: +//! links stay inside a document. `u` brings the branch back; the new file +//! stays on disk, as after `:new`. + +use std::collections::BTreeSet; +use std::path::Path; + +use super::App; +use super::action::{self, CursorAfter, DocAction, Message}; +use super::files::write_new; +use super::portals::folder_of; +use crate::model::{Content, DocUid, Document, EditOp, Link, Node, NodeId, NodeRecord, Place, Portal}; +use crate::storage::link_paths::{relative, resolve}; +use crate::storage::names::file_name; + +impl App { + /// Moves the cursor's branch into a new document: next to this one, + /// named after the branch's title, or at the path `typed`. + pub(super) fn branch_out(&mut self, typed: Option) -> Option { + if let Some(refused) = self.refuse_read_only() { + return Some(refused); + } + let here = self.buffer(); + let open = self.shared.docs.get(here); + let (doc, top) = (open.buffer.doc(), open.buffer.cursor()); + let node = doc.node(top)?; + if top == doc.root() { + return Some(Message::info("the root is the whole document: :branchout takes a branch under it")); + } + if node.is_portal() { + return Some(Message::info("this is a portal: its document is one of its own already")); + } + let folder = folder_of(&open.file.path); + let path = match typed { + Some(typed) => self.resolve_path(&typed), + None => super::files::free_path(&folder, &file_name(node.title().as_str()))?, + }; + if path.exists() { + let name = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + return Some(Message::problem(format!( + "{name} is there already: :branchout makes a new document" + ))); + } + let (branch_doc, cut) = carve(doc, top, &folder, &path)?; + let (old_uid, title, pos) = (doc.uid(), node.title().clone(), node.pos); + let loaded = match write_new(&path, branch_doc) { + Ok(loaded) => loaded, + Err(error) => return Some(Message::problem(error.short())), + }; + let (new_uid, path, count) = (loaded.doc.uid(), loaded.path.clone(), loaded.doc.node_count()); + let moved: BTreeSet = loaded.doc.outline().into_iter().collect(); + + // Here, a portal to it takes the branch's place, as one undo step. + let portal = Portal { path: relative(&folder, &path)?, uid: new_uid, title }; + let op = EditOp::BranchOut { id: top, portal: Node::portal(portal, pos) }; + let edit = DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Added, group: None }; + if let Some(problem) = self.doc_action(edit) { + return Some(problem); + } + let portal = self.current().cursor(); + + // The new document opens, with the marks that were on its nodes; + // the capital marks and the jumps on them follow too. + let marks: Vec<(char, NodeId)> = self + .current() + .marks() + .iter() + .filter(|(_, id)| moved.contains(id)) + .map(|(&name, &id)| (name, id)) + .collect(); + let (opened, said) = self.add_document(loaded); + self.shared.docs.get_mut(opened).buffer.keep_marks(marks); + let ids: BTreeSet = moved.iter().map(|id| id.get()).collect(); + self.memory.branched_out(&old_uid.to_string(), &new_uid.to_string(), &path, &ids); + for (_, pane) in self.panes.iter_mut() { + pane.jumps.branched_out(old_uid, new_uid, &path, &moved); + } + // It learns about the portal here, then the pane goes through it. + self.sync_backlinks(here); + let went = self.follow_portal(portal); + + let name = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + let mut text = format!("moved {} to {name} · Ctrl-o: back", action::nodes(count)); + if cut > 0 { + let links = if cut == 1 { "1 link".to_string() } else { format!("{cut} links") }; + text.push_str(&format!(" · {links} to the rest of the document went")); + } + Message::joined([said, went].into_iter().flatten().chain([Message::done(text)]).collect()) + } +} + +/// The document a branch makes: its top node the root, at the canvas's +/// middle, every node keeping its id and its place under the others; the +/// links among them; and its portals leading where they did, from the new +/// file's folder. Also how many links joined the branch to the rest. +fn carve(doc: &Document, top: NodeId, from: &Path, to: &Path) -> Option<(Document, usize)> { + let ids = doc.branch(top); + let inside: BTreeSet = ids.iter().copied().collect(); + let origin = doc.node(top)?.pos; + let to_folder = folder_of(to); + let records: Vec = ids + .iter() + .filter_map(|&id| { + let mut node = doc.node(id)?.clone(); + node.pos = crate::model::CanvasPos::new(node.pos.x - origin.x, node.pos.y - origin.y); + if let Content::Portal(portal) = &mut node.content + && let Some(path) = relative(&to_folder, &resolve(from, &portal.path)) + { + portal.path = path; + } + let (parent, branch) = match doc.place(id) { + Some(Place::Child { parent, branch }) if id != top => (Some(*parent), branch.clone()), + _ => (None, Default::default()), + }; + Some(NodeRecord { id, parent, branch, node }) + }) + .collect(); + let touching = |link: &&Link| inside.contains(&link.start()) || inside.contains(&link.end()); + let within = |link: &&Link| inside.contains(&link.start()) && inside.contains(&link.end()); + let links: Vec = doc.links().filter(within).cloned().collect(); + let cut = doc.links().filter(touching).count() - links.len(); + let branch = Document::from_records(DocUid::random(), top, records, links, Vec::new()).ok()?; + Some((branch, cut)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::{garden, portal}; + + #[test] + fn a_branch_makes_a_document_of_its_own() { + let (mut doc, g) = garden(); + portal(&mut doc, g.beds); + doc.apply(&EditOp::Link(Link::new(g.cedar, g.soil).unwrap())).unwrap(); + let folder = std::env::temp_dir().join("documents"); + let (branch, cut) = carve(&doc, g.beds, &folder, &folder.join("rooms").join("beds.cum")).unwrap(); + branch.assert_rules(); + assert_eq!((branch.root(), branch.title().as_str()), (g.beds, "Raised beds")); + assert_eq!( + branch.node(g.beds).unwrap().pos, + crate::model::CanvasPos::new(0, 0), + "the root in the middle" + ); + assert_eq!(branch.children(g.beds).len(), 2, "Cedar and the portal"); + assert_eq!(cut, 1, "the link from Cedar to Soil can't follow"); + let (_, moved) = branch.portals().next().unwrap(); + assert_eq!(moved.path.as_str(), "../kitchen.cum", "the portal still leads to the kitchen"); + } +} diff --git a/src/app/calls.rs b/src/app/calls.rs index 7ef1c8a..f8505ff 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -83,7 +83,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec app(AppAction::OpenConfig), Call::Tidy(layout) => commands::tidy(ctx, &visual::tops(&nodes, ctx), layout), Call::Align(side) => commands::align(ctx, on, side), - Call::BranchOut | Call::Export { .. } => later("this one comes in v1.1"), + Call::BranchOut(path) => app(AppAction::BranchOut(path)), + Call::Export { .. } => later("this one comes in v1.1"), } } diff --git a/src/app/commands.rs b/src/app/commands.rs index 6dc8987..8bfebd1 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -51,6 +51,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { Command::GoUp => app(AppAction::GoUp { list: false }), // `p`: a portal, to a file typed in `:portal`. Command::NewPortal => app(AppAction::CommandLineWith("portal ".into())), + Command::BranchOut => app(AppAction::BranchOut(None)), Command::EditTitle | Command::Activate => edit(EditAt::End), Command::EditTitleAtStart => edit(EditAt::Start), Command::RetypeTitle => edit(EditAt::Retype), diff --git a/src/app/files.rs b/src/app/files.rs index 994ddff..5f3f04d 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -93,8 +93,13 @@ pub fn load(path: &Path) -> Result { /// Makes a new document at `path` and writes it right away (`cumulus new`, /// `:new`). pub fn create(path: &Path, title: Title) -> Result { + write_new(path, Document::new(title)) +} + +/// Writes a document that's new to the file system at `path` (a new one, +/// or a branch moved out of another, `:branchout`). +pub fn write_new(path: &Path, doc: Document) -> Result { let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); - let doc = Document::new(title); let write = |error: io::Error| OpenError::Write { path: path.clone(), error }; let text = document_file::write(&doc).map_err(|error| write(io::Error::other(error)))?; write_atomically(&path, &text).map_err(write)?; @@ -722,7 +727,7 @@ impl App { /// A free file name in `folder` for `wanted` (`garden-plans.cum`): itself, /// or with `-2`, `-3`… before `.cum`. -fn free_path(folder: &Path, wanted: &str) -> Option { +pub(super) fn free_path(folder: &Path, wanted: &str) -> Option { let stem = wanted.strip_suffix(&format!(".{}", names::EXTENSION)).unwrap_or(wanted); (1..1000) .map(|n| if n == 1 { wanted.to_string() } else { format!("{stem}-{n}.{}", names::EXTENSION) }) diff --git a/src/app/jumps.rs b/src/app/jumps.rs index 459b114..d9888e4 100644 --- a/src/app/jumps.rs +++ b/src/app/jumps.rs @@ -4,6 +4,7 @@ //! Each pane has its own; a new pane starts with a copy of its first //! pane's. A place in another document opens it, even after it was closed. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use super::action::{DocAction, Message}; @@ -73,6 +74,15 @@ impl Jumps { } } + /// Nodes moved to another document (`:branchout`), keeping their ids: + /// the places on them follow. + pub fn branched_out(&mut self, from: DocUid, to: DocUid, path: &Path, nodes: &BTreeSet) { + for place in self.places.iter_mut().filter(|place| place.uid == from && nodes.contains(&place.node)) { + place.uid = to; + place.path = path.to_path_buf(); + } + } + /// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on /// (none until they're used), for `:jumps`. pub fn list(&self) -> (&[Place], Option) { diff --git a/src/app/mod.rs b/src/app/mod.rs index d38044e..d4071e5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -19,6 +19,7 @@ //! what `cumulus` opens, and [`run`] is the main loop on a real terminal. pub mod action; +mod branch_out; pub mod buffer; pub mod calls; mod clipboard; diff --git a/src/app/popup.rs b/src/app/popup.rs index e426221..d8b6ddc 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -890,6 +890,7 @@ const NODE_MENU: &[(&str, Command)] = &[ ("style…", Command::PickStyle), ("tags…", Command::EditTags), ("move it", Command::MoveMode), + ("move it to a document of its own", Command::BranchOut), ("open it in the Note view", Command::ShowNote), ("delete just this node", Command::DeleteNode), ("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)), diff --git a/src/app/tests.rs b/src/app/tests.rs index 0287bdb..30ea9b4 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -4,6 +4,7 @@ //! `tests/files.rs`, and the config folder in `tests/config.rs`. mod arrange; +mod branch_out; mod config; mod connect; mod files; @@ -506,7 +507,7 @@ fn indenting_and_outdenting_nodes() { #[test] fn v1_1_commands_say_so_and_portal_titles_stay() { let mut h = Harness::new(); - h.type_keys(":branchout"); + h.type_keys(":export md"); assert_eq!(h.message(), "this one comes in v1.1"); // Kitchen is a portal: its title is the other document's name. h.type_keys("glgjgjgj"); diff --git a/src/app/tests/branch_out.rs b/src/app/tests/branch_out.rs new file mode 100644 index 0000000..cbd7a62 --- /dev/null +++ b/src/app/tests/branch_out.rs @@ -0,0 +1,85 @@ +//! Branching out (part 9b), end to end: a branch moves into a document of +//! its own, a portal takes its place, and the pane goes there. + +use super::*; + +#[test] +fn space_o_moves_a_branch_into_a_document_of_its_own() { + let mut h = Harness::new(); + // Marks on Cedar, which is about to move. + h.type_keys("glgjglmAmaghi"); + assert_eq!(h.cursor(), "Raised beds"); + h.type_keys("o"); + assert_eq!(h.message(), "moved 3 nodes to raised-beds.cum · Ctrl-o: back"); + assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Raised beds", "Raised beds")); + assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]); + let color = h.doc().node(h.node("Raised beds")).unwrap().look.color.clone(); + assert_eq!( + color.map(|color| color.as_str().to_string()).as_deref(), + Some("orange"), + "its look came along" + ); + h.type_keys("gl"); + let rows = h.rows(); + let crumbs = "Garden plans » Raised beds › Cedar, untreated"; + assert!(rows[rows.len() - 2].contains(crumbs), "as through the portal:\n{}", h.screen()); + assert!(h.file("raised-beds.cum").contains("from: \"garden.cum\""), "it knows the portal"); + // The marks on Cedar followed it. + h.type_keys("gg'a"); + assert_eq!(h.cursor(), "Cedar, untreated"); + h.type_keys("gg'A"); + assert_eq!(h.cursor(), "Cedar, untreated"); + // Ctrl-o: back to the portal that took the branch's place. + h.type_keys("gg"); + assert_eq!(h.doc().title().as_str(), "Garden plans"); + assert_eq!(h.children("Garden plans"), ["Soil", "Raised beds", "Watering", "Kitchen"]); + assert!(h.doc().node(h.node("Raised beds")).unwrap().is_portal()); + assert!(h.screen().contains("» Raised beds"), "{}", h.screen()); + // u brings the branch back; the new file stays. + h.type_keys("u"); + assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]); + assert!(h.path("raised-beds.cum").exists()); +} + +#[test] +fn links_across_go_and_portals_inside_move_along() { + let mut h = Harness::new(); + // Seeds to order is linked to "ask about compost", outside its branch. + h.type_keys(":find seedso"); + assert_eq!( + h.message(), + "moved 3 nodes to seeds-to-order.cum · Ctrl-o: back · 1 link to the rest of the document went" + ); + // The kitchen portal under Watering, then Watering into another folder. + h.type_keys(""); + assert_eq!(h.doc().title().as_str(), "Garden plans"); + h.type_keys("ggglgjgjgj"); + assert_eq!(h.cursor(), "Kitchen"); + h.type_keys("Cgk>"); + assert_eq!(h.parent("Kitchen"), "Watering"); + h.type_keys("gh:branchout rooms/water.cum"); + assert_eq!(h.doc().title().as_str(), "Watering"); + let path = h.doc().portals().next().map(|(_, portal)| portal.path.as_str().to_string()); + assert_eq!(path.as_deref(), Some("../kitchen.cum"), "it still leads to the kitchen"); + h.wait(1); + let kitchen = h.file("kitchen.cum"); + assert!(kitchen.contains("from: \"rooms/water.cum\"") && !kitchen.contains("garden.cum"), "{kitchen}"); + // Through it, to the kitchen. + h.type_keys(":find kitchen"); + assert_eq!(h.doc().title().as_str(), "Kitchen"); +} + +#[test] +fn what_branch_out_says_no_to() { + let mut h = Harness::new(); + h.type_keys("o"); + assert_eq!(h.message(), "the root is the whole document: :branchout takes a branch under it"); + h.type_keys("glgjgjgjo"); + assert_eq!(h.message(), "this is a portal: its document is one of its own already"); + h.type_keys("gk:branchout kitchen"); + assert_eq!(h.message(), "kitchen.cum is there already: :branchout makes a new document"); + // The right-click menu has it too. + let watering = h.find("Watering"); + h.right_click(watering); + assert!(h.screen().contains("move it to a document of its own"), "{}", h.screen()); +} diff --git a/src/app/update.rs b/src/app/update.rs index 979b31d..41deb1e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -125,6 +125,7 @@ impl App { AppAction::FollowPortal(id) => return self.follow_portal(id), AppAction::NewPortal(path) => return self.new_portal(&path), AppAction::GoUp { list } => return self.go_up(list), + AppAction::BranchOut(path) => return self.branch_out(path), AppAction::ToggleWhole => { if let Mode::Move(grab) = &mut self.shared.mode { grab.whole = !grab.whole; diff --git a/src/command/parse.rs b/src/command/parse.rs index 2b013d4..3137e1e 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -84,7 +84,9 @@ pub enum Call { Link(NodeId), Unlink(Option), Portal(String), - BranchOut, + /// A branch into a document of its own, at this path (or next to this + /// one, named after it). + BranchOut(Option), Tags(Vec), /// A palette color, or `None` to clear it. Color(Option), @@ -244,7 +246,7 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R Id::Link => Call::Link(node(text, &sources.nodes)?), Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?), Id::Portal => Call::Portal(text.to_string()), - Id::BranchOut => Call::BranchOut, + Id::BranchOut => Call::BranchOut(first.map(str::to_string)), Id::Tags => Call::Tags(tag_changes(text)?), Id::Color => Call::Color(color(text, &sources.colors)?), Id::Style => Call::Style(style(text, &sources.styles)?), diff --git a/src/command/registry.rs b/src/command/registry.rs index 204bbf6..1eb3b3b 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -284,7 +284,13 @@ pub const SPECS: &[Spec] = &[ spec(Id::Link, "link", "li", &[required("node", K::Node)], "link this node to another one"), spec(Id::Unlink, "unlink", "unl", &[optional("node", K::Node)], "remove a link (or all of this node's)"), spec(Id::Portal, "portal", "por", &[required("file", K::File)], "add a portal to another document"), - spec(Id::BranchOut, "branchout", "bran", &[], "move this branch into a document of its own"), + spec( + Id::BranchOut, + "branchout", + "bran", + &[optional("file", K::File)], + "move this branch into a document of its own", + ), with_aliases( &["tags"], spec(Id::Tags, "tag", "ta", &[required("tags", K::Tags)], "add and remove tags: +idea -todo"), diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 6da10e1..8906382 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -237,6 +237,7 @@ const NORMAL: &[DefaultBinding] = &[ bind("B", C::PickBorder), bind("t", C::EditTags), bind("p", C::NewPortal), + bind("o", C::BranchOut), bind("=m", C::Tidy(Layout::Map)), bind("=c", C::Tidy(Layout::Chart)), bind("=g", C::Tidy(Layout::Grid)), diff --git a/src/input/command.rs b/src/input/command.rs index a0ecfdf..8633067 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -309,6 +309,8 @@ pub enum Command { PickBorder, EditTags, NewPortal, + /// `o`: the cursor's branch into a document of its own. + BranchOut, Capture, // Modes. @@ -520,6 +522,7 @@ pub const COMMANDS: &[CommandInfo] = &[ info("pick-border", C::PickBorder, "border…"), info("edit-tags", C::EditTags, "tags…"), info("new-portal", C::NewPortal, "new portal…"), + info("branch-out", C::BranchOut, "this branch out, to a document of its own"), info("capture", C::Capture, "capture an idea to the inbox…"), // Modes. info("visual", C::Visual, "select"), diff --git a/src/model/op.rs b/src/model/op.rs index 625a5bd..ca31dc2 100644 --- a/src/model/op.rs +++ b/src/model/op.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use super::clip::Clip; -use super::document::Document; +use super::document::{Document, Place}; use super::edit::{Change, Edit, EditError, Spot}; use super::geometry::{CanvasOffset, CanvasPos}; use super::ids::NodeId; @@ -97,6 +97,13 @@ pub enum EditOp { key: LinkKey, label: Option