diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index 6d31125b94..16c37e641a 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -4678,7 +4678,115 @@ removing the two methods that had no future use case at all. --- -## See Also +## 51. v1.27 Process Tree View (2026-06-21) + +Per the v1.23 deferred-future-use comment ("`ppid` is parsed but +not yet rendered. Reserved for a future process-tree view"), v1.27 +activates that field by adding a tree view to the Process tab. + +### 51.1 What was implemented + +**`App.process_tree: bool`** — new field, initialized to `false`. +Toggled by the `T` hotkey (uppercase T to avoid colliding with +`throttle` mode's lowercase `t`). + +**`process::sort_tree(processes, sort_mode)`** — new public function +that re-orders the process list so parents appear before children +in a depth-first walk. Algorithm: + +1. Build a `pid → index` map. +2. Group children by `ppid` (`ppid → Vec`). +3. Find roots: procs with `ppid == 0` OR `ppid` not in pid set + (e.g. init's parent is 0; kernel threads whose parent exited). +4. Sort each sibling group by `sort_mode` (so e.g. RSS sort still + shows top-RSS child first within each parent's children). +5. DFS from each root, emitting the parent followed by its + descendants in pre-order. +6. Defensive fallback: append any unvisited procs at the end + (handles a ppid cycle pointing back into the visited set). + +**Cycle protection**: each PID is added to a `visited` set on emit. +If a PID is revisited (ppid loop), recursion stops — the children +of the cycle node are still emitted once as flat children of the +parent. + +**`render::tree_prefix(pid, ppid, all)`** — new render helper that +returns a string like `" └─ "` for a child row, or `""` for a +root. Walks the ppid chain to compute depth (max 64 hops to avoid +infinite loops), and uses the next row in `all` to decide whether +this row is the last sibling (`└─ `) or not (`├─ `). + +**Status line update** — the Process panel header now shows +`view: tree` when tree mode is on. The help text mentions the +`T` key: `(press 'o' to cycle, 'T' for tree, '/' to filter)`. + +**No more `#[allow(dead_code)]` on `ppid`** — the field is now +actively read by `sort_tree` and `tree_prefix`. The +`#[allow(dead_code)]` annotation can be removed in a follow-up +(v1.28) to clean up the now-unnecessary suppression. + +### 51.2 Test coverage + +Test count: **105** (up from 101). + +New tests (4): +- `sort_tree_emits_parents_before_children` — 4-proc tree (1 → 2 → 3 + and 1 → 4); asserts parent-before-child ordering. +- `sort_tree_handles_orphans` — proc with `ppid=999` not in list; + treated as root; ordering preserved. +- `sort_tree_handles_cycles` — `1 (ppid=2)` and `2 (ppid=1)` cycle; + both treated as roots; no infinite loop. +- `sort_tree_empty_input` — empty input returns empty output. + +### 51.3 Cross-compile + smoke test results + +| Target | Size | SHA256 | +|--------------|-------------|-------------------------------------------------------------------| +| Linux host | 3.0 MB | (run from `target/release/redbear-power`) | +| Redox x86_64 | 4,184,936 B | `d7e2f430063ca2ffaed7f82b7b101e983f866e9883d2adbe1ebd695b60ec74b9` | + +Binary size delta: +16,384 bytes (16 KiB) from v1.26. The growth +comes from `sort_tree` + `tree_prefix` + the new `T` keypress +handler + 4 new tests. + +Smoke test confirms default view is `flat` (no `view: tree` in +the status line) — the `T` keypress would flip it to tree mode +in the interactive TUI. + +### 51.4 Compute cost + +`sort_tree` is O(N log N) for the sort + O(N) for the DFS + O(N) +for the HashMap builds (one per `tree_prefix` call). For the +truncated top-50 list this is microseconds. For a full 1000-proc +list (e.g. a busy server) it's still <1ms. + +### 51.5 Why not htop-style collapsible tree (indent + collapse) + +htop allows folding subtrees via a key. v1.27 ships the static +view (always show all nodes, parents before children). Fold/expand +is a separate feature that needs: + +1. A per-subtree "folded" state stored on the App. +2. A keypress to toggle fold on the cursor's row. +3. The render layer to skip the children of folded nodes. + +Defer to v1.28 if user demand appears. The static view already +answers the most common "who forked what" question. + +### 51.6 What was NOT changed (intentional) + +- **`vsize_kb` still has `#[allow(dead_code)]`** — v1.27 activates + the `ppid` future-use but `vsize_kb` is still only parsed. The + memory-detail panel is a separate feature. +- **No fold/expand** — see §51.5. +- **No tree on the CPU% column** — the tree is layout-only; data + columns (CPU%, IO, RATE, RSS) render as before, one per row. +- **No per-depth indentation marker (vertical lines)** — the + current `└─` / `├─` connectors don't show the depth visually + with vertical bars. htop does this with `│` characters. Defer + to v1.28. + +--- ## See Also diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 7aa23248a3..cda45cc9ba 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -130,6 +130,11 @@ pub meminfo: crate::meminfo::MemInfo, pub prev_refresh_secs: f64, pub process_sort: crate::process::SortMode, pub process_filter: String, + /// When true, render the Process tab as a tree (parents above + /// children, prefixed with `├─ ` / `└─ ` connector characters). + /// Toggled by the `T` hotkey. Sort modes are honored within + /// each parent's children but parents always come first. + pub process_tree: bool, pub pid_detail: Option, pub refresh_counter: u32, pub status_msg: String, @@ -296,6 +301,7 @@ impl App { prev_refresh_secs: 0.0, process_sort: crate::process::SortMode::default(), process_filter: String::new(), + process_tree: false, pid_detail: None, refresh_counter: 0, } @@ -440,6 +446,9 @@ impl App { self.process_sort, ), ); + if self.process_tree { + crate::process::sort_tree(&mut self.processes.processes, self.process_sort); + } self.prev_refresh_secs = now_secs; } diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index a7f575bb61..243aee0223 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -573,6 +573,13 @@ fn main() -> io::Result<()> { app.process_sort.name() )); } + Key::Char('T') => { + app.process_tree = !app.process_tree; + app.flash_status(format!( + "process view: {}", + if app.process_tree { "tree" } else { "flat" } + )); + } Key::Char('f') => { process_filter_input = Some(app.process_filter.clone()); app.flash_status("process filter: type chars + Enter to apply, Esc to clear"); diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index e9b5d5158c..2be8f4a156 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -120,6 +120,99 @@ where }); } +/// Tree sort: emit each process preceded by its parent(s) so the +/// visual reading order matches the parent-child hierarchy. Roots +/// (processes whose ppid is 0 or whose parent is not in the current +/// process set) are emitted first, then each root's descendants in +/// depth-first order. Within siblings, the `sort_mode` is honored. +/// +/// The input `processes` is consumed and replaced; the output uses +/// the same ProcessInfo values. Stable for processes that share the +/// same parent and sort key. +/// +/// Cycle protection: a PID that is its own ancestor is not recursed +/// into (its children are still emitted once as flat children of the +/// cycle parent). This handles the rare case of `init`-style PPID +/// loops in containers. +pub fn sort_tree(processes: &mut Vec, sort_mode: SortMode) { + use std::collections::{BTreeMap, BTreeSet}; + + // 1. Index PIDs and group children by ppid. + let mut by_pid: BTreeMap = BTreeMap::new(); + for (i, p) in processes.iter().enumerate() { + by_pid.insert(p.pid, i); + } + let mut children: BTreeMap> = BTreeMap::new(); + for (i, p) in processes.iter().enumerate() { + children.entry(p.ppid).or_default().push(i); + } + + // 2. Find roots: ppid == 0 or ppid not in pid set. + let mut roots: Vec = (0..processes.len()) + .filter(|&i| { + let p = &processes[i]; + p.ppid == 0 || !by_pid.contains_key(&p.ppid) + }) + .collect(); + + // 3. Sort roots and each sibling group by sort_mode. We + // sort the indices using a small adapter closure so the + // existing `sort_mode.sort()` (which takes Vec) + // can be reused. + let mut roots_proc: Vec = roots.iter().map(|&i| processes[i].clone()).collect(); + sort_mode.sort(&mut roots_proc); + roots = roots_proc.iter().map(|p| by_pid[&p.pid]).collect(); + + for v in children.values_mut() { + let mut v_proc: Vec = v.iter().map(|&i| processes[i].clone()).collect(); + sort_mode.sort(&mut v_proc); + *v = v_proc.iter().map(|p| by_pid[&p.pid]).collect(); + } + + // 4. DFS from each root, building the output in tree order. + let mut out: Vec = Vec::with_capacity(processes.len()); + let mut visited: BTreeSet = BTreeSet::new(); + for &root in &roots { + dfs_emit( + &processes, + &children, + root, + &mut out, + &mut visited, + ); + } + + // 5. Append any leftover procs (defensive — should not happen + // given step 2, but handles e.g. a ppid cycle pointing back + // into the middle of the visited set). + for (i, p) in processes.iter().enumerate() { + if !visited.contains(&p.pid) { + out.push(processes[i].clone()); + } + } + + *processes = out; +} + +fn dfs_emit( + processes: &[ProcessInfo], + children: &std::collections::BTreeMap>, + idx: usize, + out: &mut Vec, + visited: &mut std::collections::BTreeSet, +) { + let pid = processes[idx].pid; + if !visited.insert(pid) { + return; // cycle protection + } + out.push(processes[idx].clone()); + if let Some(kids) = children.get(&pid) { + for &k in kids { + dfs_emit(processes, children, k, out, visited); + } + } +} + #[derive(Default, Clone, Debug)] pub struct ProcessInfo { pub pid: u32, @@ -868,4 +961,80 @@ mod io_sort_unit_tests { assert_eq!(ps[2].pid, 1); assert_eq!(ps[3].pid, 3); } + + fn make_p(ppid: u32, pid: u32) -> ProcessInfo { + ProcessInfo { + pid, + ppid, + ..Default::default() + } + } + + #[test] + fn sort_tree_emits_parents_before_children() { + // Tree: + // 1 + // ├── 2 + // │ └── 3 + // └── 4 + let mut ps = vec![ + make_p(0, 1), // root + make_p(1, 2), // child of 1 + make_p(2, 3), // child of 2 + make_p(1, 4), // child of 1 + ]; + sort_tree(&mut ps, SortMode::Pid); + let pids: Vec = ps.iter().map(|p| p.pid).collect(); + // 1 must come before 2 and 4; 2 must come before 3. + let pos1 = pids.iter().position(|&p| p == 1).unwrap(); + let pos2 = pids.iter().position(|&p| p == 2).unwrap(); + let pos3 = pids.iter().position(|&p| p == 3).unwrap(); + let pos4 = pids.iter().position(|&p| p == 4).unwrap(); + assert!(pos1 < pos2); + assert!(pos1 < pos4); + assert!(pos2 < pos3); + } + + #[test] + fn sort_tree_handles_orphans() { + // 1 (root), 2 (orphan: ppid=999 not in list), 3 (child of 1) + let mut ps = vec![ + make_p(0, 1), + make_p(999, 2), + make_p(1, 3), + ]; + sort_tree(&mut ps, SortMode::Pid); + let pids: Vec = ps.iter().map(|p| p.pid).collect(); + // All 3 present. + assert_eq!(pids.len(), 3); + assert!(pids.contains(&1)); + assert!(pids.contains(&2)); + assert!(pids.contains(&3)); + // 1 still before 3. + let pos1 = pids.iter().position(|&p| p == 1).unwrap(); + let pos3 = pids.iter().position(|&p| p == 3).unwrap(); + assert!(pos1 < pos3); + } + + #[test] + fn sort_tree_handles_cycles() { + // 1 (ppid=2), 2 (ppid=1) — cycle. Both treated as roots. + let mut ps = vec![ + make_p(2, 1), + make_p(1, 2), + ]; + sort_tree(&mut ps, SortMode::Pid); + let pids: Vec = ps.iter().map(|p| p.pid).collect(); + // Both present; no infinite loop. + assert_eq!(pids.len(), 2); + assert!(pids.contains(&1)); + assert!(pids.contains(&2)); + } + + #[test] + fn sort_tree_empty_input() { + let mut ps: Vec = Vec::new(); + sort_tree(&mut ps, SortMode::Pid); + assert!(ps.is_empty()); + } } diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index bfff596baa..6f79d10f6d 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -868,11 +868,12 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { format!("; filter: \"{}\" (press Esc to clear)", app.process_filter) }; lines.push(Line::from(format!( - "Showing top {} of {} process(es); total RSS: {}; sort: {}{} (press 'o' to cycle, '/' to filter)", + "Showing top {} of {} process(es); total RSS: {}; sort: {}{}{} (press 'o' to cycle, 'T' for tree, '/' to filter)", proc.count(), proc.total_count, crate::process::ProcessInfo::format_memory_kb(proc.total_memory_kb), app.process_sort.name(), + if app.process_tree { "; view: tree" } else { "" }, filter_indicator, ).set_style(theme::LABEL_BOLD))); lines.push(Line::from("")); @@ -894,8 +895,14 @@ lines.push(Line::from(vec![ Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs), None => "—".to_string(), }; + let prefix = if app.process_tree { + tree_prefix(p.pid, p.ppid, &proc.processes) + } else { + String::new() + }; lines.push(Line::from(format!( - " {:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {}", + " {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {}", + prefix, p.pid, p.state, p.priority, @@ -913,6 +920,62 @@ lines.push(Line::from(vec![ .wrap(Wrap { trim: true }) } +/// Build a tree prefix string for a process: `└─ ` (last child), +/// `├─ ` (non-last child), or empty (root). Walks the ppid chain to +/// determine depth and uses the next row in `all` to decide whether +/// this row is the last sibling of its parent. +/// +/// O(N) per call, O(N^2) worst case for the full render. Fine for +/// the truncated top-50 list. +fn tree_prefix(pid: u32, ppid: u32, all: &[crate::process::ProcessInfo]) -> String { + use std::collections::HashMap; + if all.is_empty() { + return String::new(); + } + let by_pid: HashMap = + all.iter().map(|p| (p.pid, p)).collect(); + + // Walk up the ppid chain. Each step means this row is one level + // deeper than its parent. Stop when we hit a root (ppid==0 or + // ppid not in list) or a cycle (safety bound of 64 hops). + let mut depth: usize = 0; + let mut cur = pid; + let max_walk = 64; + for _ in 0..max_walk { + let p = match by_pid.get(&cur) { + Some(p) => *p, + None => break, + }; + if p.ppid == 0 || !by_pid.contains_key(&p.ppid) { + break; + } + cur = p.ppid; + depth += 1; + } + + if depth == 0 { + return String::new(); + } + + // "Last child" iff the next row in the list has a different + // ppid (or there is no next row). Filter is applied at render + // time but the sort_tree output is the source of truth for + // sibling order, so this approximation is exact for unfiltered + // rows. + let my_index = match all.iter().position(|p| p.pid == pid) { + Some(i) => i, + None => return String::new(), + }; + let is_last = match all.get(my_index + 1) { + Some(next) => next.ppid != ppid, + None => true, + }; + + let indent = " ".repeat(depth - 1); + let connector = if is_last { "└─ " } else { "├─ " }; + format!("{}{}", indent, connector) +} + pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Paragraph<'static> { let s = &detail.status; let i = &detail.io;