Files
cumulus/src/app/tests/portals.rs
T
skkeyeandClaude Opus 5.5 ebe8a5988e 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]>
2026-10-02 08:24:43 -04:00

193 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"
);
}