Add connect mode, portals, backlinks and renames (stage 8d, v1)

Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1
list.

Connect mode (C): the cursor goes to another node, a dashed arrow in the
visual color follows it, and Enter links the two; then the cursor goes
back to where C was typed (the user's pick). d removes the line between
them (a branch is cut, and the child comes loose), > puts the node under
the other one, and <Space> a, l, c, s and t style the line as you watch,
making it when it isn't there yet. Everything connect mode changes is one
undo group, and Esc takes it back with History::cancel, which leaves
nothing to redo. From visual mode every selected node connects, and the
line pickers go back to connect mode when they close.

Portals show their document live, from a cache of what each document
file holds (open documents from their buffers, the others from their
files, looked at every second): the root's title, its color and how many
nodes it has, or ? when the document isn't there. Enter or gf goes
through, as a jump, and the pane's breadcrumb starts with the documents
it came through. <Space>p types :portal (the user's pick): a path as :e
takes it, or the title of a new document. Backlinks are put right
whenever a document is opened or saved; the root shows how many, the
Note view lists them, gu goes up to them (the user's pick) and
:backlinks lists them.

File names follow the root's title: when it changed, the next save
renames the file (-2 when the name is taken; a folder's default document
keeps its name), and the portals pointing at it, its backlinks in the
documents it leads to, the recent documents, the capital marks and the
jump lists follow. A portal whose file was moved by hand finds its
document in that folder by uid. Portals in a register keep full paths,
so a pasted portal leads to the same document from any folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 08:24:43 -04:00
1 parent bd819409f4
commit ebe8a5988e
45 files changed
+2329 -148

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
//! Portals (part 8d), end to end: what a portal shows, going through one
//! and back, `gu` up to the portals that lead here, `:portal`, backlinks
//! kept in step, and file names that follow titles.
use super::*;
impl Harness {
/// The status bar's first line.
fn status_line(&self) -> String {
let rows = self.rows();
rows[rows.len() - 2].clone()
}
/// The row of the screen where `text` is.
fn row_of(&self, text: &str) -> String {
let (_, row) = self.find(text);
self.rows()[usize::from(row)].clone()
}
}
/// The garden on its own, with the kitchen's file renamed to `pantry.cum`
/// behind its portal's back.
fn garden_with_the_kitchen_moved() -> Harness {
let folder = TempDir::new();
std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap();
std::fs::write(folder.path().join("pantry.cum"), KITCHEN).unwrap();
Harness::open(folder, &["garden.cum"])
}
#[test]
fn a_portal_shows_its_document_live() {
let mut h = Harness::new();
let (col, row) = h.find("» Kitchen");
// The kitchen's color (its root's), and how many nodes it has.
assert_eq!(h.color_at((col - 2, row)), ratatui::style::Color::Indexed(178), "yellow, as Kitchen is");
assert!(h.rows()[usize::from(row) + 1].contains(" 5 "), "{}", h.screen());
// A node added in the kitchen counts at once.
h.type_keys("gtaPans<CR><Esc><Esc>gT");
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" 6 "), "{}", h.screen());
// So does a new title.
h.type_keys("gt:rename Cooking<CR>gT");
assert!(h.screen().contains("» Cooking"), "{}", h.screen());
}
#[test]
fn enter_goes_through_a_portal_and_ctrl_o_comes_back() {
let mut h = Harness::new();
h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
assert_eq!(h.cursor(), "Kitchen", "on its root");
assert!(h.status_line().contains("Garden plans » Kitchen"), "the way here: {}", h.status_line());
h.type_keys("gl");
assert!(h.status_line().contains("Garden plans » Kitchen › Herbs on the sill"), "{}", h.status_line());
// Ctrl-o: back to the portal, and the breadcrumb is the garden's again.
h.type_keys("<C-o>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
assert!(h.status_line().contains(" Garden plans › Kitchen"), "{}", h.status_line());
h.type_keys("gf");
assert_eq!(h.doc().title().as_str(), "Kitchen", "gf goes through too");
// The Note view says where a portal leads.
h.type_keys("<C-o><Space>vn");
assert!(h.screen().contains("a portal to kitchen.cum · 5 nodes · Enter goes there"), "{}", h.screen());
}
#[test]
fn gu_goes_up_to_the_portals_that_lead_here() {
let mut h = Harness::new();
h.type_keys("gt");
assert_eq!(h.doc().title().as_str(), "Kitchen");
// The root counts the portals that lead to its document.
assert!(h.row_of("Kitchen").contains("Kitchen") && h.screen().contains("« 1"), "{}", h.screen());
h.type_keys("glgu");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"), "on the portal");
// :backlinks lists them, even just one.
h.type_keys("<C-o>:backlinks<CR>");
assert!(h.screen().contains("portals that lead here"), "{}", h.screen());
assert!(h.screen().contains("« Garden plans garden.cum"), "{}", h.screen());
h.type_keys("<CR>");
assert_eq!(h.cursor(), "Kitchen");
// In the Note view, the root lists them as parts (after the title, the
// tags, the body and two children): Enter goes there.
h.type_keys("gtgg<Space>vn");
assert!(h.screen().contains("Portals here"), "{}", h.screen());
h.type_keys("jjjjj<CR>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
// A document no portal leads to says so.
h.type_keys(":new Shopping list<CR>gu");
assert_eq!(h.message(), "no portal leads here yet: :portal in another document makes one");
}
#[test]
fn portal_makes_a_new_document_or_finds_one() {
let mut h = Harness::new();
h.type_keys("<Space>p");
assert_eq!(h.label(), "COMMAND");
assert!(h.rows().last().unwrap().starts_with(":portal "), "{}", h.screen());
h.type_keys("Compost bins<CR>");
assert_eq!(h.message(), "made compost-bins.cum, with a portal to it: Enter goes there");
assert_eq!(h.cursor(), "Compost bins");
assert_eq!(h.parent("Compost bins"), "Garden plans", "under the cursor's node, where a adds one");
assert!(h.file("compost-bins.cum").contains("title: \"Compost bins\""));
// Saved, the portal puts a backlink in its document.
h.wait(1);
let made = h.file("compost-bins.cum");
assert!(made.contains("from: \"garden.cum\""), "{made}");
// One that's there already, by its file's name or its title.
h.type_keys("gg:portal kitchen<CR>");
assert_eq!(h.message(), "a portal to kitchen.cum: Enter goes there");
h.type_keys("gg:portal Compost bins<CR>");
assert_eq!(h.message(), "a portal to compost-bins.cum: Enter goes there");
h.type_keys("gg:portal garden<CR>");
assert_eq!(h.message(), "a portal can't lead to its own document");
}
#[test]
fn backlinks_follow_the_portals_as_they_come_and_go() {
let mut h = Harness::new();
// The kitchen portal goes: once the garden is saved, the kitchen
// forgets it.
h.type_keys("glgjgjgjdd");
h.wait(1);
assert!(!h.file("kitchen.cum").contains("backlinks"), "{}", h.file("kitchen.cum"));
h.type_keys("gt");
assert!(!h.screen().contains("« 1"), "{}", h.screen());
// Back again: so is the backlink, with the portal's node.
h.type_keys("gTu");
h.wait(1);
assert!(h.file("kitchen.cum").contains("node: 10"), "{}", h.file("kitchen.cum"));
}
#[test]
fn a_new_title_renames_the_file_and_its_portals_follow() {
let mut h = Harness::new();
h.type_keys("gt:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "kitchen.cum is cooking.cum now: a file's name follows its title");
assert!(!h.path("kitchen.cum").exists());
assert!(h.file("cooking.cum").contains("title: \"Cooking\""));
let garden = h.file("garden.cum");
assert!(garden.contains("path: \"cooking.cum\"") && garden.contains("title: \"Cooking\""), "{garden}");
// The portal still goes there.
h.type_keys("gTglgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Cooking");
// A name that's taken gets a number.
h.type_keys("gT:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "garden.cum is cooking-2.cum now: a file's name follows its title");
assert!(h.file("cooking.cum").contains("from: \"cooking-2.cum\""), "the backlink follows it too");
// A folder's default document keeps its name.
h.type_keys(":new<CR>:rename Everything<CR>");
h.wait(1);
assert!(h.path("ideas.cum").exists() && !h.path("everything.cum").exists());
}
#[test]
fn a_portal_finds_its_document_moved_by_hand() {
let mut h = garden_with_the_kitchen_moved();
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" ? "), "not where it says: {}", h.screen());
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen", "found in its folder by its uid");
assert!(h.file("garden.cum").contains("path: \"pantry.cum\""), "and the portal keeps that path");
// Gone for good: Enter says so.
let mut h = garden_with_the_kitchen_moved();
std::fs::remove_file(h.path("pantry.cum")).unwrap();
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.message(), "kitchen.cum isn't there anymore");
assert_eq!(h.doc().title().as_str(), "Garden plans");
}
#[test]
fn a_portal_pasted_into_another_folder_still_leads_there() {
let mut h = Harness::new();
h.type_keys("glgjgjgjyy:e rooms/attic.cum<CR>p");
let path = h.doc().portals().next().map(|(_, portal)| portal.path.as_str().to_string());
assert_eq!(path.as_deref(), Some("../kitchen.cum"), "from the attic's own folder");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
}
#[test]
fn w_says_when_the_file_takes_a_new_name() {
let mut h = Harness::new();
h.type_keys("gt:rename Pantry<CR>:w<CR>");
assert_eq!(
h.message(),
"saved pantry.cum · kitchen.cum is pantry.cum now: a file's name follows its title"
);
}