redbear-power: v1.27 process tree view

Activates the v1.23-deferred 'ppid' future-use: parents render
above their children with ASCII tree connectors. The default
view is flat (no behavior change); the 'T' hotkey toggles
tree mode and flashes the status line.

Algorithm (in process::sort_tree):
1. Build pid -> index map
2. Group children by ppid (ppid -> Vec<index>)
3. Roots = ppid==0 or ppid not in pid set
4. Sort each sibling group by current SortMode (so e.g. RSS
   sort still shows top-RSS child first within a parent)
5. DFS from each root, emitting parent + descendants pre-order
6. Defensive: append unvisited procs at end (cycle fallback)

Cycle protection: visited set; revisiting a PID stops recursion
(its children are still emitted once).

Render: tree_prefix(pid, ppid, all) returns
  ''  (root)
  '  \u2514\u2500 ' (last child)
  '  \u251c\u2500 ' (non-last child)
Walks ppid chain to compute depth (max 64 hops).

Status line: 'view: tree' shown when on; help text mentions 'T'.

Test count 101 -> 105 (+4):
- sort_tree_emits_parents_before_children (4-proc tree)
- sort_tree_handles_orphans (ppid not in list)
- sort_tree_handles_cycles (1->2->1 cycle)
- sort_tree_empty_input

Redox stripped binary: 4,184,936 bytes (+16 KiB from v1.26).
Compile warnings: 55 (unchanged).

Notes:
- vsize_kb still has #[allow(dead_code)]; will be activated in
  a future memory-detail panel release.
- Tree is static (no fold/expand); defer to a v1.28 if needed.
- ppid's #[allow(dead_code)] can be removed in a follow-up
  (now actively read by sort_tree and tree_prefix).

Docs: local/docs/redbear-power-improvement-plan.md \xC2\xA751
This commit is contained in:
2026-06-21 01:39:29 +03:00
parent 6c30edaf3e
commit 3dcdb758e7
5 changed files with 359 additions and 3 deletions
+109 -1
View File
@@ -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<index>`).
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
@@ -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<crate::pid_detail::PidDetail>,
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;
}
@@ -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");
@@ -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<ProcessInfo>, sort_mode: SortMode) {
use std::collections::{BTreeMap, BTreeSet};
// 1. Index PIDs and group children by ppid.
let mut by_pid: BTreeMap<u32, usize> = BTreeMap::new();
for (i, p) in processes.iter().enumerate() {
by_pid.insert(p.pid, i);
}
let mut children: BTreeMap<u32, Vec<usize>> = 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<usize> = (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<ProcessInfo>)
// can be reused.
let mut roots_proc: Vec<ProcessInfo> = 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<ProcessInfo> = 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<ProcessInfo> = Vec::with_capacity(processes.len());
let mut visited: BTreeSet<u32> = 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<u32, Vec<usize>>,
idx: usize,
out: &mut Vec<ProcessInfo>,
visited: &mut std::collections::BTreeSet<u32>,
) {
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<u32> = 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<u32> = 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<u32> = 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<ProcessInfo> = Vec::new();
sort_tree(&mut ps, SortMode::Pid);
assert!(ps.is_empty());
}
}
@@ -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<u32, &crate::process::ProcessInfo> =
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;