Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down
1 change: 1 addition & 0 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
_ => {}
}
Expand Down
23 changes: 23 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(()) };
Expand Down
46 changes: 46 additions & 0 deletions crates/plannotator-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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");
}
134 changes: 122 additions & 12 deletions crates/plannotator-tui/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Row>,
}

Expand All @@ -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<Vec<Row>> {
/// Dot-prefixed entries are listed only when `show_hidden`; `SKIPPED_DIRS` never are.
fn list(dir: &Path, depth: usize, show_hidden: bool) -> Result<Vec<Row>> {
let mut entries: Vec<PathBuf> = 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();
Expand Down Expand Up @@ -84,7 +107,29 @@ fn list(dir: &Path, depth: usize) -> Result<Vec<Row>> {
impl Tree {
/// The root's own entries; nothing beneath is touched until a directory is expanded.
pub(crate) fn scan(root: &Path) -> Result<Self> {
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<PathBuf> =
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 {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<PathBuf> {
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());
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<String> = 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");
Expand Down
Loading