From 42a28f55def518c103475250827c8853501c069e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 13:23:46 -0700 Subject: [PATCH 1/3] feat(tree): list hidden entries on demand Dot-prefixed entries stay out of the tree unless the view asks for them, and a hidden directory is read only once it is both shown and expanded, so the default path still touches nothing extra. Version-control, dependency and build directories join one documented denylist that is never listed or read either way. refs plannotator/herdr-annotate#53 --- crates/plannotator-tui/src/tree.rs | 134 ++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 12 deletions(-) diff --git a/crates/plannotator-tui/src/tree.rs b/crates/plannotator-tui/src/tree.rs index a893fda..f68e3c9 100644 --- a/crates/plannotator-tui/src/tree.rs +++ b/crates/plannotator-tui/src/tree.rs @@ -2,17 +2,36 @@ //! //! Scanning eagerly held a blank pane for minutes on big trees (plannotator-tui#44 review), //! so the tree lists only the root's entries at open, and a directory's children when it is -//! expanded. Hidden entries, non-Markdown files, dependency/build directories and symlinked -//! directories are skipped. Rows carry their depth and expansion state; the vec stays the +//! expanded. Non-Markdown files, dependency/build directories and symlinked directories are +//! skipped. Hidden (dot-prefixed) entries are skipped too until the view asks for them; a +//! hidden directory is read only once it is both shown and expanded, so the default path +//! never touches one. Rows carry their depth and expansion state; the vec stays the //! flattened visible list, so the view and hit-testing stay a plain slice. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -/// Directories that hold dependencies or build output, never docs worth listing. -const SKIPPED_DIRS: [&str; 8] = - ["node_modules", "target", "vendor", "dist", "build", "out", "__pycache__", "venv"]; +/// Directories that hold dependencies, build output or version-control internals: never +/// listed and never read, not even when hidden entries are shown. A repository's `.git` +/// alone is large enough to stall the pane, and none of these hold docs worth reviewing. +const SKIPPED_DIRS: [&str; 15] = [ + ".cache", + ".direnv", + ".git", + ".hg", + ".jj", + ".svn", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "out", + "target", + "vendor", + "venv", +]; #[derive(Debug, Clone)] pub(crate) struct Row { @@ -29,6 +48,9 @@ pub(crate) struct Row { #[derive(Debug)] pub(crate) struct Tree { root: PathBuf, + /// Whether dot-prefixed entries are listed. A view choice: the annotations recorded for + /// a file inside a hidden folder belong to the review either way. + show_hidden: bool, pub(crate) rows: Vec, } @@ -49,12 +71,13 @@ fn is_walkable_dir(path: &Path) -> bool { } /// One directory's rows at `depth`: markdown files first, then subdirectories, both sorted. -fn list(dir: &Path, depth: usize) -> Result> { +/// Dot-prefixed entries are listed only when `show_hidden`; `SKIPPED_DIRS` never are. +fn list(dir: &Path, depth: usize, show_hidden: bool) -> Result> { let mut entries: Vec = std::fs::read_dir(dir) .with_context(|| format!("reading {}", dir.display()))? .filter_map(Result::ok) .map(|e| e.path()) - .filter(|p| !is_hidden(p)) + .filter(|p| show_hidden || !is_hidden(p)) .collect(); entries.sort(); let mut rows = Vec::new(); @@ -84,7 +107,29 @@ fn list(dir: &Path, depth: usize) -> Result> { impl Tree { /// The root's own entries; nothing beneath is touched until a directory is expanded. pub(crate) fn scan(root: &Path) -> Result { - Ok(Self { root: root.to_path_buf(), rows: list(root, 0)? }) + Ok(Self { root: root.to_path_buf(), show_hidden: false, rows: list(root, 0, false)? }) + } + + pub(crate) fn show_hidden(&self) -> bool { + self.show_hidden + } + + /// Show or hide dot-prefixed entries, relisting what is currently open. Directories that + /// were expanded stay expanded; one that can no longer be read is left collapsed rather + /// than failing the whole relist. + pub(crate) fn set_show_hidden(&mut self, show_hidden: bool) -> Result<()> { + self.show_hidden = show_hidden; + let expanded: Vec = + self.rows.iter().filter(|r| r.is_dir && r.expanded).map(|r| r.path.clone()).collect(); + self.rows = list(&self.root, 0, show_hidden)?; + let mut index = 0; + while let Some(row) = self.rows.get(index) { + if row.is_dir && expanded.contains(&row.path) { + let _ = self.toggle(index); + } + index += 1; + } + Ok(()) } pub(crate) fn root(&self) -> &Path { @@ -114,7 +159,7 @@ impl Tree { let end = self.end_of_subtree(index); self.rows.drain(index + 1..end); } else { - let children = list(&path, depth + 1)?; + let children = list(&path, depth + 1, self.show_hidden)?; let at = index + 1; self.rows.splice(at..at, children); } @@ -158,14 +203,14 @@ impl Tree { } } -/// The first markdown file at or near the top of `root`: the shallowest match, found by a -/// breadth-first look that gives up after `budget` entries. Big trees stay fast; the caller +/// The first markdown file at or near the top of `root`, hidden entries excluded: the +/// shallowest match, found by a breadth-first look that gives up after `budget` entries. Big trees stay fast; the caller /// shows a placeholder when nothing shallow exists. pub(crate) fn first_file_shallow(root: &Path, budget: usize) -> Option { let mut queue = std::collections::VecDeque::from([root.to_path_buf()]); let mut seen = 0usize; while let Some(dir) = queue.pop_front() { - let Ok(rows) = list(&dir, 0) else { continue }; + let Ok(rows) = list(&dir, 0, false) else { continue }; seen += rows.len(); if let Some(file) = rows.iter().find(|r| !r.is_dir) { return Some(file.path.clone()); @@ -193,12 +238,17 @@ mod tests { std::fs::create_dir_all(root.join("docs/deep")).expect("mkdir"); std::fs::create_dir_all(root.join("empty")).expect("mkdir"); std::fs::create_dir_all(root.join(".hidden")).expect("mkdir"); + std::fs::create_dir_all(root.join(".agents/drafts")).expect("mkdir"); + std::fs::create_dir_all(root.join(".git/objects")).expect("mkdir"); std::fs::create_dir_all(root.join("node_modules/pkg")).expect("mkdir"); std::fs::write(root.join("b.md"), "").expect("write"); std::fs::write(root.join("a.MD"), "").expect("write"); std::fs::write(root.join("notes.txt"), "").expect("write"); std::fs::write(root.join("docs/deep/plan.md"), "").expect("write"); std::fs::write(root.join(".hidden/x.md"), "").expect("write"); + std::fs::write(root.join(".agents/drafts/draft.md"), "").expect("write"); + std::fs::write(root.join(".git/objects/pack.md"), "").expect("write"); + std::fs::write(root.join(".git/config.md"), "").expect("write"); std::fs::write(root.join("node_modules/pkg/readme.md"), "").expect("write"); #[cfg(unix)] std::os::unix::fs::symlink(&root, root.join("loop")).expect("symlink"); @@ -256,6 +306,66 @@ mod tests { std::fs::remove_dir_all(&root).expect("cleanup"); } + #[test] + fn hidden_entries_appear_only_once_they_are_asked_for() { + let root = fixture("hidden"); + let mut tree = Tree::scan(&root).expect("scan"); + assert!(!tree.show_hidden()); + let visible_only = shape(&tree); + tree.set_show_hidden(true).expect("show hidden"); + assert_eq!( + shape(&tree), + [ + row("a.MD", 0, false), + row("b.md", 0, false), + row(".agents", 0, true), + row(".hidden", 0, true), + row("docs", 0, true), + row("empty", 0, true), + ] + ); + // A hidden directory still lists lazily: its markdown shows once it is expanded. + let agents = tree.position(root.join(".agents").as_path()).expect(".agents row"); + assert!(tree.toggle(agents).expect("expand .agents")); + let drafts = tree.position(root.join(".agents/drafts").as_path()).expect("drafts row"); + assert!(tree.toggle(drafts).expect("expand drafts")); + assert_eq!(tree.position(root.join(".agents/drafts/draft.md").as_path()), Some(drafts + 1)); + // Hiding again restores exactly the default view, expansions and all. + tree.set_show_hidden(false).expect("hide hidden"); + assert_eq!(shape(&tree), visible_only); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn skipped_directories_are_never_listed_or_read() { + let root = fixture("skipped"); + let mut tree = Tree::scan(&root).expect("scan"); + tree.set_show_hidden(true).expect("show hidden"); + let names: Vec = tree.rows.iter().map(|r| r.name.clone()).collect(); + for skipped in SKIPPED_DIRS { + assert!(!names.iter().any(|n| n == skipped), "{skipped} must stay out of the tree"); + } + // `.git` has markdown at both levels and is the row the cursor could expand; with no + // row for it there is no way to read it, so neither file can reach the tree. + assert_eq!(tree.position(root.join(".git").as_path()), None); + assert_eq!(tree.position(root.join(".git/config.md").as_path()), None); + // An expanded `.git` cannot even be reconstructed by relisting. + tree.set_show_hidden(false).expect("hide"); + tree.set_show_hidden(true).expect("show again"); + assert!(!tree.rows.iter().any(|r| r.path.starts_with(root.join(".git")))); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn the_shallowest_markdown_ignores_hidden_folders() { + let root = fixture("shallow-hidden"); + std::fs::remove_file(root.join("a.MD")).expect("rm"); + std::fs::remove_file(root.join("b.md")).expect("rm"); + std::fs::remove_dir_all(root.join("docs")).expect("rm docs"); + assert_eq!(first_file_shallow(&root, 2000), None); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + #[test] fn the_shallowest_markdown_is_found_without_walking_everything() { let root = fixture("shallow"); From 1076c6c1b2bea08f6d7221094a8ff4e8b823b477 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 13:23:46 -0700 Subject: [PATCH 2/3] feat(app): toggle hidden tree entries with `.` `.` in tree focus shows or hides dot-prefixed entries for the session and says which in the status line, keeping the cursor on its row. Showing is a view choice: review files come from the records on disk, so notes inside a hidden folder are counted and sent whether or not the folder is listed. refs plannotator/herdr-annotate#53 --- crates/plannotator-tui/src/app/draw.rs | 2 +- crates/plannotator-tui/src/app/input.rs | 1 + crates/plannotator-tui/src/app/mod.rs | 23 +++++++++++++ crates/plannotator-tui/src/app/tests.rs | 46 +++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index 9a44667..ca4bc27 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -417,7 +417,7 @@ impl App { } let help = match self.focus { _ if self.pending.is_some() => "a looks good · c comment · d delete · esc clear ", - Focus::Tree => "j/k · enter open · E send · t hide · q quit ", + Focus::Tree => "j/k · enter open · . hidden · E send · t hide · q quit ", Focus::Rail => "j/k · e edit · x remove · tab · q quit ", Focus::Document if self.roam => "hjkl move · v select · c comment · esc blocks · q quit ", Focus::Document => "i move · v select · c comment · E send · tab · q quit ", diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index c0acf07..238fb60 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -127,6 +127,7 @@ impl App { } KeyCode::Char('k') | KeyCode::Up => self.tree_cursor = self.tree_cursor.saturating_sub(1), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.open_tree_selection()?, + KeyCode::Char('.') => self.toggle_tree_hidden()?, KeyCode::Esc => self.focus = Focus::Document, _ => {} } diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index b222686..78e9d00 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -314,6 +314,29 @@ impl App { } } + /// Show or hide dot-prefixed entries in the tree. A view choice only: annotations + /// recorded for a file inside a hidden folder are part of the review either way, so + /// what `E` sends and what the review counts add up to do not change here. + fn toggle_tree_hidden(&mut self) -> Result<()> { + let Some(mut tree) = self.tree.take() else { return Ok(()) }; + let selected = tree.rows.get(self.tree_cursor).map(|r| r.path.clone()); + let result = tree.set_show_hidden(!tree.show_hidden()); + self.refresh_counts(&mut tree); + self.status = Some( + if tree.show_hidden() { "hidden entries shown" } else { "hidden entries hidden" }.to_owned(), + ); + // Keep the cursor on the same row where the relist still lists it. + self.tree_cursor = selected + .and_then(|path| tree.position(&path)) + .unwrap_or_else(|| self.tree_cursor.min(tree.rows.len().saturating_sub(1))); + self.tree = Some(tree); + result?; + self.refresh_review_counts(); + self.derive_send_state(); + self.keep_tree_cursor_visible(usize::from(self.geometry.tree.height)); + Ok(()) + } + /// Open the file under the tree cursor, or expand/collapse a directory. fn open_tree_selection(&mut self) -> Result<()> { let Some(row) = self.tree.as_ref().and_then(|t| t.rows.get(self.tree_cursor)) else { return Ok(()) }; diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 95b574c..e576650 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -498,3 +498,49 @@ fn a_column_move_in_block_mode_starts_roaming_so_the_cursor_is_drawn() { app.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)).expect("v"); assert_eq!(app.selection.map(|s| s.anchor()), Some((0, 2)), "v anchors where the cursor is shown"); } + +/// A folder whose only Markdown lives beside a hidden folder and a `.git` full of it. +fn folder_with_hidden() -> PathBuf { + let root = std::env::temp_dir().join(format!("plannotator-tui-hidden-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".agents/drafts")).expect("mkdir"); + std::fs::create_dir_all(root.join(".git")).expect("mkdir"); + std::fs::write(root.join("plan.md"), "# Plan\n\nfirst thing\n").expect("write"); + std::fs::write(root.join(".agents/drafts/draft.md"), "# Draft\n\nnotes\n").expect("write"); + std::fs::write(root.join(".git/COMMIT_EDITMSG.md"), "# Commit\n").expect("write"); + root +} + +#[test] +fn dot_in_the_tree_shows_hidden_folders_but_never_the_skipped_ones() { + let root = folder_with_hidden(); + let mut app = App::open_folder(&root, 100, Box::new(Discard)).expect("folder opens"); + app.data_dir = scratch_data_dir(); + draw_sized(&mut app, 140, 20); + let names = |app: &App| -> Vec { + app.tree.as_ref().expect("tree").rows.iter().map(|r| r.name.clone()).collect() + }; + assert_eq!(names(&app), ["plan.md"], "hidden folders stay out of the default view"); + + app.handle_event(&key(KeyCode::Tab, KeyModifiers::NONE)).expect("tab"); + app.handle_event(&key(KeyCode::Char('.'), KeyModifiers::NONE)).expect("dot"); + assert_eq!(names(&app), ["plan.md", ".agents"], "the toggle lists .agents, never .git"); + assert_eq!(app.status.as_deref(), Some("hidden entries shown")); + + // The hidden folder opens like any other: expand down to its Markdown and read it. + app.handle_event(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("expand .agents"); + app.handle_event(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("expand drafts"); + app.handle_event(&key(KeyCode::Char('j'), KeyModifiers::NONE)).expect("j"); + app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("open draft"); + assert_eq!(open_path(&app), "draft.md"); + + // Hiding again leaves the default view, with the document it opened still open. + app.handle_event(&key(KeyCode::Tab, KeyModifiers::NONE)).expect("tab"); + app.handle_event(&key(KeyCode::Char('.'), KeyModifiers::NONE)).expect("dot"); + assert_eq!(names(&app), ["plan.md"]); + assert_eq!(app.status.as_deref(), Some("hidden entries hidden")); + assert_eq!(open_path(&app), "draft.md", "hiding a folder does not close its open file"); + std::fs::remove_dir_all(&root).expect("cleanup"); +} From 7d803a98fd1fef0dca70b9661987de33a22a70fb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 21 Sep 2026 13:23:46 -0700 Subject: [PATCH 3/3] docs: note the tree's hidden-entry toggle refs plannotator/herdr-annotate#53 --- README.md | 8 +++++++- crates/plannotator-tui/README.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f36521a..8a81a68 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,13 @@ dimmed. The keys also work without opening the menu. | toolbar | `a` looks good · `c` comment · `d` delete · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; click a bubble | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | -| tree | `j`/`k`; `Enter` open; `E` sends new notes across all reviewed files, including collapsed folders | +| tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders | + +Hidden folders are out of the tree until `.` asks for them, and `.git`, `.hg`, `.svn`, +`.jj`, `.cache`, `.direnv`, `.venv`, `venv`, `__pycache__`, `node_modules`, `vendor`, +`target`, `build`, `dist` and `out` stay out either way — none of them is read at all. +Showing or hiding is a view choice: notes recorded for a file inside a hidden folder are +part of the review and are sent whether or not its folder is listed. ## Inside Herdr diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 52f243f..d8fac97 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -17,7 +17,7 @@ cargo build --release | selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears | | rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | -| tree | `j`/`k` move · `Enter` open · `E` send new feedback across files, including collapsed folders · counts show active notes per file | +| tree | `j`/`k` move · `Enter` open · `.` show/hide hidden (dot-prefixed) entries · `E` send new feedback across files, including collapsed folders · counts show active notes per file | | archive | `j`/`k` or ↑/↓ select · `Enter` or click restore · `Esc` close | Selections and exports are copied to the terminal clipboard (OSC 52).