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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user