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]>
193 lines
8.3 KiB
Rust
193 lines
8.3 KiB
Rust
//! 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"
|
||
);
|
||
}
|