Branch a branch out into a document of its own (stage 9b)

<Space>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 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 11:25:57 -04:00
1 parent 3b8f7c0b3c
commit 5888d143fd
19 files changed
+343 -13

No files matched your search

+4
View File
@@ -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 `<Space>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<String>),
/// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one.
GoUp {
+158
View File
@@ -0,0 +1,158 @@
//! Branching out (`:branchout`, `<Space>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<String>) -> Option<Message> {
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<NodeId> = 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<u32> = 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<NodeId> = ids.iter().copied().collect();
let origin = doc.node(top)?.pos;
let to_folder = folder_of(to);
let records: Vec<NodeRecord> = 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<Link> = 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");
}
}
+2 -1
View File
@@ -83,7 +83,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec<Acti
Call::Config => 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"),
}
}
+1
View File
@@ -51,6 +51,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::GoUp => app(AppAction::GoUp { list: false }),
// `<Space>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),
+7 -2
View File
@@ -93,8 +93,13 @@ pub fn load(path: &Path) -> Result<Loaded, OpenError> {
/// Makes a new document at `path` and writes it right away (`cumulus new`,
/// `:new`).
pub fn create(path: &Path, title: Title) -> Result<Loaded, OpenError> {
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<Loaded, OpenError> {
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<PathBuf> {
pub(super) fn free_path(folder: &Path, wanted: &str) -> Option<PathBuf> {
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) })
+10
View File
@@ -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<NodeId>) {
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<usize>) {
+1
View File
@@ -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;
+1
View File
@@ -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)),
+2 -1
View File
@@ -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<CR>");
h.type_keys(":export md<CR>");
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");
+85
View File
@@ -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<Esc><Esc>");
assert_eq!(h.cursor(), "Raised beds");
h.type_keys("<Space>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<C-o><C-o><C-o>");
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 seeds<CR><Space>o");
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("<C-o>");
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<CR>");
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<CR><CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
}
#[test]
fn what_branch_out_says_no_to() {
let mut h = Harness::new();
h.type_keys("<Space>o");
assert_eq!(h.message(), "the root is the whole document: :branchout takes a branch under it");
h.type_keys("glgjgjgj<Space>o");
assert_eq!(h.message(), "this is a portal: its document is one of its own already");
h.type_keys("gk:branchout kitchen<CR>");
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());
}
+1
View File
@@ -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;