From 9cd0a25906fb967d8079c35f679dd68d6d2fdb49 Mon Sep 17 00:00:00 2001 From: vasilito Date: Sun, 21 Jun 2026 09:50:31 +0300 Subject: [PATCH] redbear-power: v1.38 audit fixes + htop/btop parity v1.37 audit found 2 new bugs + recommended 5 v1.38 htop/btop-parity features. This release fixes both bugs and ships all 5 features. v1.37-0 (HIGH): set_tab() clears last_clicked_cpu The v1.37 re-click-to-expand feature set last_clicked_cpu on click but never reset it on tab switch. A user who clicked Per-CPU row 5, switched tabs, and came back would unexpectedly toggle expand. Fix: add App::set_tab(TabId) helper that resets both last_clicked_cpu and expanded_cpu, and route all 9 tab keys (1-9) + T through it. v1.37-1 (MEDIUM): mouse click respects filter The Process tab mouse click set process_cursor from the raw screen row, ignoring the active filter. With a filter active, the cursor highlight wouldn't align with the click, and right-click opened the wrong PID detail. Fix: new App::process_cursor_at_y(y, first_data_y) that walks the post-filter visible list and clamps to the last visible row. Wired into both left-click and right-click in handle_mouse. v1.38-2: SortDir + i key for direction toggle htop parity for the 'i' key. New App.sort_ascending: bool. The SortMode enum gets a new sort_ascending(procs, true) method (the existing sort() now delegates to sort_ascending(procs, false) for backward compat). On each refresh, if sort_ascending is true, the processes are re-sorted after the default descending pass. Press 'i' to flip; the status flash includes the current direction. v1.38-3: cmdline + io_priority in PID detail htop parity. New PidDetail.cmdline reads /proc/[pid]/cmdline, replaces NUL with space, strips trailing NULs. Rendered in the PID detail popup (truncated to 120 chars). New PidDetail.io_priority reads /proc/[pid]/stat field 47. Both are tolerant of missing files. v1.38-4: per-disk I/O throughput sparkline btop parity. New App.disk_history: BTreeMap> keyed by disk name. Mirrors the io_history pattern: each storage refresh collects raw kbps samples, normalizes per-disk against its own max, writes u8 to the public history. Rendered in the Storage tab as a 12-char sparkline next to each disk name. Reaps disks that have disappeared. Test count 140 -> 149 (+9): - set_tab_clears_last_clicked_cpu_and_expanded_cpu - process_cursor_at_y_respects_filter - process_cursor_at_y_clamps_to_last_visible - sort_ascending_flips_rss_order - read_cmdline_replaces_nul_with_space - read_cmdline_handles_missing_pid - read_io_priority_handles_self - read_io_priority_handles_missing_pid - update_disk_history_reaps_exited_disks Redox stripped binary: 4,348,776 bytes (+106 KiB from v1.37). Compile warnings: 56 (unchanged; all pre-existing). --- .../system/redbear-power/source/src/app.rs | 173 ++++++++++++++++++ .../system/redbear-power/source/src/main.rs | 48 +++-- .../redbear-power/source/src/pid_detail.rs | 88 +++++++++ .../redbear-power/source/src/process.rs | 141 +++++++++++--- .../system/redbear-power/source/src/render.rs | 33 ++++ 5 files changed, 440 insertions(+), 43 deletions(-) diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 5474d28b8d..3f87ee4130 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -158,6 +158,12 @@ pub meminfo: crate::meminfo::MemInfo, /// Per-PID RSS history (normalized, 0..=255 against the /// per-history max). Same shape. pub rss_history: std::collections::BTreeMap>, + /// Per-disk I/O throughput history (normalized 0..=255). + /// Keyed by disk name (e.g. "sda", "nvme0n1"). Value is + /// the last N samples of total throughput (read + write + /// KiB/s) normalized per-disk against its own max. v1.38: + /// btop parity. + pub disk_history: std::collections::BTreeMap>, /// Cursor index into the visible (post-filter) process list. /// Distinct from `table_state` which tracks the Per-CPU tab. pub process_cursor: usize, @@ -165,6 +171,10 @@ pub meminfo: crate::meminfo::MemInfo, /// same row toggles expand (mirrors htop). `None` until /// the first click. pub last_clicked_cpu: Option, + /// Current sort direction for the Process tab. htop's `i` + /// key toggles this; v1.38 adds a `SortDir` state so the + /// comparator and the column header can reflect it. + pub sort_ascending: bool, pub pid_detail: Option, pub refresh_counter: u32, pub status_msg: String, @@ -335,8 +345,10 @@ impl App { folded: std::collections::BTreeSet::new(), io_history: std::collections::BTreeMap::new(), last_clicked_cpu: None, + sort_ascending: false, cpu_history: std::collections::BTreeMap::new(), rss_history: std::collections::BTreeMap::new(), + disk_history: std::collections::BTreeMap::new(), process_cursor: 0, pid_detail: None, refresh_counter: 0, @@ -437,6 +449,7 @@ impl App { dt, ), ); + self.update_disk_history(); // SMART is a subprocess call (5-50ms per disk); do it // at the same 11-tick cadence as Storage to avoid // duplicating the wall-clock cost. @@ -482,6 +495,17 @@ impl App { self.processes.processes = filtered; } } + // v1.38: re-sort by the App's direction flag if + // ascending. The default sort (descending) is + // already applied by read_with_cpu_pct_sorted; an + // extra pass here is needed only when the user + // toggled to ascending via the `i` key. v1.39+ + // can preserve the cursor position across the + // re-sort. + if self.sort_ascending { + self.process_sort + .sort_ascending(&mut self.processes.processes, true); + } self.update_io_history(); self.prev_refresh_secs = now_secs; } @@ -770,6 +794,41 @@ impl App { } } + /// Switch to `tab`, resetting cross-tab UI state that no + /// longer makes sense. v1.37 introduced `last_clicked_cpu` + /// to power re-click-to-expand; without a reset on tab + /// switch, a user who clicks Per-CPU row 5, switches to + /// another tab, and clicks the same Per-CPU row 5 on + /// return would get an unexpected expand. Centralized + /// here so the v1.37.1 bug fix is one place. + pub fn set_tab(&mut self, tab: TabId) { + self.current_tab = tab; + self.last_clicked_cpu = None; + self.expanded_cpu = None; + } + + /// Map a click y-offset (relative to the Process tab body) + /// to a `process_cursor` index that respects the active + /// filter. v1.37 mouse handler used the raw screen row + /// (`y - table.y - 3`) which ignored the filter — clicking + /// visible row 5 when half the procs were filtered out + /// would land on a non-visible process. v1.38 walks the + /// post-filter list and counts only the visible rows the + /// user actually sees. + /// `first_data_y` is the y of the first data row (panel + /// top + 3: title, blank, column header, then rows). + pub fn process_cursor_at_y(&mut self, y: u16, first_data_y: u16) { + let visible = self.visible_processes(); + if visible.is_empty() { + self.process_cursor = 0; + return; + } + let row_offset = y.saturating_sub(first_data_y) as usize; + // Clamp to the last visible row (clicking below the last + // row clamps to the last row, not to 0). + self.process_cursor = row_offset.min(visible.len() - 1); + } + /// Return the count of visible (post-filter) processes. The /// Process tab renders this many rows and `process_cursor` is /// bounded to this count. @@ -889,6 +948,54 @@ impl App { } } + /// Update per-disk I/O throughput histories from the + /// current `self.storage`. Mirrors the per-PID + /// `update_io_history` algorithm: collect raw samples + /// into a pending Vec, normalize against per-disk + /// max, write u8 to the public `disk_history` map. + pub fn update_disk_history(&mut self) { + // 1. Reap exited disks. + let current_names: std::collections::BTreeSet = self + .storage + .disks + .iter() + .map(|d| d.name.clone()) + .collect(); + self.disk_history + .retain(|name, _| current_names.contains(name)); + + // 2. Collect raw throughput samples per disk. + let mut pending: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for d in &self.storage.disks { + let total_kbps = d.stats.read_kbps + d.stats.write_kbps; + pending.entry(d.name.clone()).or_default().push(total_kbps); + } + + // 3. Normalize and commit. + for (name, samples) in pending { + let max = samples.iter().copied().fold(0.0_f64, f64::max); + let history = self + .disk_history + .entry(name) + .or_insert_with(|| std::collections::VecDeque::with_capacity(PROCESS_IO_HISTORY_LEN)); + history.clear(); + if max <= 0.0 { + for _ in 0..samples.len() { + history.push_back(0); + } + continue; + } + for s in samples { + let normalized = ((s / max) * 255.0).round(); + history.push_back(normalized.clamp(0.0, 255.0) as u8); + while history.len() > PROCESS_IO_HISTORY_LEN { + history.pop_front(); + } + } + } + } + /// Page-scroll the selection by `pages` rows. PageDown moves /// down (positive pages); PageUp moves up (negative pages). /// The per-row offset for "one page" is a UX convention — 8 @@ -1153,6 +1260,21 @@ mod tests { } } + #[test] + fn update_disk_history_reaps_exited_disks() { + // v1.38: per-disk throughput history reap. A disk + // that was in the history but is no longer in + // self.storage.disks (e.g. USB drive unplugged) + // should be removed. + let mut app = App::new(); + app.disk_history.insert( + "ghost-disk".to_string(), + std::collections::VecDeque::from(vec![128u8]), + ); + app.update_disk_history(); + assert!(!app.disk_history.contains_key("ghost-disk")); + } + #[test] fn update_io_history_skips_pids_without_rate() { // procs without io_read_kb/io_write_kb (None) should NOT @@ -1223,6 +1345,57 @@ mod tests { assert_eq!(app.process_cursor, 0); } + #[test] + fn process_cursor_at_y_respects_filter() { + // v1.38 fix for the v1.37 mouse-click filter bug. The + // renderer iterates with a post-filter visible_index; + // the click handler must do the same. With 5 procs + // (pids 100..104) and a filter matching only pid 101 + // ("proc1"), clicking visible row 0 must set the + // cursor to the first visible (pid 101), not the first + // raw (pid 100). + let mut app = make_app_with_processes(5); + app.process_filter = "proc1".to_string(); + app.process_cursor = 0; + // Click y=0 maps to the first data row. + app.process_cursor_at_y(0, 0); + assert_eq!(app.process_cursor, 0, + "first visible row is the only matching proc, cursor stays at 0"); + assert_eq!(app.selected_pid(), Some(101), + "selected_pid should be the visible proc, not the first raw proc (pid 100)"); + } + + #[test] + fn process_cursor_at_y_clamps_to_last_visible() { + // Click below the last visible row should clamp to the + // last visible row, not wrap to 0. + let mut app = make_app_with_processes(5); + app.process_cursor = 0; + // first_data_y=0; click y=100 is far below any row. + app.process_cursor_at_y(100, 0); + assert_eq!(app.process_cursor, 4, "should clamp to last visible (pid 104)"); + } + + #[test] + fn set_tab_clears_last_clicked_cpu_and_expanded_cpu() { + // v1.37.1 fix: switching tabs must reset the re-click- + // to-expand state. Otherwise a user who clicks Per-CPU + // row 5, switches to another tab, and clicks the same + // Per-CPU row 5 on return would unexpectedly toggle + // expand. The fix is centralized in set_tab. + let mut app = App::new(); + // Simulate the user having clicked Per-CPU row 5. + app.last_clicked_cpu = Some(5); + app.expanded_cpu = Some(5); + // Tab switch (any tab). + app.set_tab(TabId::Process); + assert!(app.last_clicked_cpu.is_none(), + "set_tab must clear last_clicked_cpu"); + assert!(app.expanded_cpu.is_none(), + "set_tab must clear expanded_cpu"); + assert_eq!(app.current_tab, TabId::Process); + } + #[test] fn page_selection_process_jumps_by_8_rows() { // PageDown/PageUp for the Process tab should move the diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index b1cf2506ca..80c32a5fba 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -173,14 +173,13 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap app.cycle_governor(); } else if app.current_tab == TabId::Process { // Process tab uses the full body_area as its panel. - // Click in the body maps to visible_index. The - // Process tab header is at body.y; rows start at - // body.y + 3 (line 0 is the title; line 1 is blank; - // line 2 is the column header; rows start at line 3). + // Click in the body maps to a post-filter visible + // row. v1.38 fix: App::process_cursor_at_y respects + // the active filter (the v1.37 raw-row math + // ignored the filter and landed on hidden processes). let body = Rect::new(table.x, table.y, table.width, table.height); if hit_test(body, x, y) { - let row = y.saturating_sub(table.y + 3) as usize; - app.process_cursor = row; + app.process_cursor_at_y(y, table.y + 3); } } } @@ -193,8 +192,7 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap // Right-click on a process row opens the PID detail. let body = Rect::new(table.x, table.y, table.width, table.height); if hit_test(body, x, y) { - let row = y.saturating_sub(table.y + 3) as usize; - app.process_cursor = row; + app.process_cursor_at_y(y, table.y + 3); if let Some(pid) = app.selected_pid() { app.pid_detail = Some(crate::pid_detail::PidDetail::read(pid)); } @@ -472,16 +470,16 @@ fn main() -> io::Result<()> { Key::BackTab => { focused_panel = if focused_panel == 0 { 2 } else { focused_panel - 1 }; } - Key::Char('1') => app.current_tab = app::TabId::PerCpu, - Key::Char('2') => app.current_tab = app::TabId::System, - Key::Char('3') => app.current_tab = app::TabId::Info, - Key::Char('4') => app.current_tab = app::TabId::Motherboard, - Key::Char('5') => app.current_tab = app::TabId::Battery, - Key::Char('6') => app.current_tab = app::TabId::Sensors, - Key::Char('7') => app.current_tab = app::TabId::Network, - Key::Char('8') => app.current_tab = app::TabId::Storage, - Key::Char('9') => app.current_tab = app::TabId::Process, - Key::Char('T') => app.current_tab = app.current_tab.next(), + Key::Char('1') => app.set_tab(app::TabId::PerCpu), + Key::Char('2') => app.set_tab(app::TabId::System), + Key::Char('3') => app.set_tab(app::TabId::Info), + Key::Char('4') => app.set_tab(app::TabId::Motherboard), + Key::Char('5') => app.set_tab(app::TabId::Battery), + Key::Char('6') => app.set_tab(app::TabId::Sensors), + Key::Char('7') => app.set_tab(app::TabId::Network), + Key::Char('8') => app.set_tab(app::TabId::Storage), + Key::Char('9') => app.set_tab(app::TabId::Process), + Key::Char('T') => app.set_tab(app.current_tab.next()), Key::Char('?') => show_help = !show_help, Key::Esc if app.pid_detail.is_some() => { app.pid_detail = None; @@ -602,8 +600,18 @@ fn main() -> io::Result<()> { Key::Char('o') => { app.process_sort = app.process_sort.next(); app.flash_status(format!( - "process sort: {}", - app.process_sort.name() + "process sort: {} {}", + app.process_sort.name(), + if app.sort_ascending { "asc" } else { "desc" } + )); + } + Key::Char('i') => { + // htop parity: invert sort direction. + app.sort_ascending = !app.sort_ascending; + app.flash_status(format!( + "process sort: {} {}", + app.process_sort.name(), + if app.sort_ascending { "asc" } else { "desc" } )); } Key::Char('T') => { diff --git a/local/recipes/system/redbear-power/source/src/pid_detail.rs b/local/recipes/system/redbear-power/source/src/pid_detail.rs index 342b24e1b0..b279813e02 100644 --- a/local/recipes/system/redbear-power/source/src/pid_detail.rs +++ b/local/recipes/system/redbear-power/source/src/pid_detail.rs @@ -66,6 +66,14 @@ pub struct PidDetail { pub status: ProcStatus, pub io: ProcIo, pub smaps: ProcSmapsRollup, + /// `/proc//cmdline` with NUL separators replaced by + /// spaces. v1.38: htop parity. `None` if the file is + /// missing (process exited) or unreadable (CAP_PTRACE). + pub cmdline: Option, + /// I/O scheduler priority from `/proc//stat` field + /// 47. v1.38: htop parity. `None` if absent (older + /// kernels). + pub io_priority: Option, } impl PidDetail { @@ -77,10 +85,57 @@ impl PidDetail { status: read_status(pid), io: read_io(pid), smaps: read_smaps_rollup(pid), + cmdline: read_cmdline(pid), + io_priority: read_io_priority(pid), } } } +/// Read `/proc//cmdline`. NUL separators (between argv +/// elements) are replaced with single spaces; trailing NULs +/// are stripped. Returns `None` if the file is missing. +fn read_cmdline(pid: u32) -> Option { + let bytes = fs::read(format!("/proc/{pid}/cmdline")).ok()?; + // cmdline uses NUL as the separator. Some kernels emit + // a trailing NUL that produces a spurious trailing space; + // strip it. + let trimmed: Vec = bytes + .into_iter() + .map(|b| if b == 0 { b' ' } else { b }) + .collect(); + let s = String::from_utf8(trimmed).ok()?.trim_end().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Read the I/O priority class from `/proc//stat` field +/// 47 (the priority is `class | (data << 13)`; we return the +/// raw field as u32 so the caller can interpret both). +/// Field 47 is the 47th space-separated token, 0-indexed at +/// the THIRD field (state) plus 44 (matches the kernel's +/// `getpriority(2)`-derived field). +fn read_io_priority(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // The comm field is wrapped in parens and may contain + // spaces, so we must find the LAST `)` to delimit the + // comm field, then split the rest by whitespace. The + // 44th post-comm field (1-indexed: field 47 overall) + // holds the priority. + let close = stat.rfind(')')?; + let tail = &stat[close + 1..]; + let fields: Vec<&str> = tail.split_whitespace().collect(); + // post-comm field index 44 -> vec index 43 (0-indexed) + // post-comm field 1 is the state (after the comm), so + // 47th overall field is post-comm 45 (state + 44). + if fields.len() < 45 { + return None; + } + fields[44].parse::().ok() +} + fn read_status(pid: u32) -> ProcStatus { let path = format!("{PROC_STATUS_PREFIX}{pid}{PROC_STATUS_SUFFIX}"); let Ok(content) = fs::read_to_string(&path) else { return ProcStatus::default() }; @@ -234,4 +289,37 @@ mod tests { // At minimum, status.name should be set (parses /proc/self/status) assert!(detail.status.name.is_some()); } + + #[test] + fn read_cmdline_replaces_nul_with_space() { + // Self's cmdline should be non-empty and use spaces + // (not NULs) as separators. + let cmdline = read_cmdline(std::process::id()); + assert!(cmdline.is_some(), "self cmdline should be readable"); + let s = cmdline.unwrap(); + assert!(s.len() > 0); + assert!(!s.contains('\0'), "cmdline must not contain NULs"); + // At least one space in most real cmdlines. + // (Skip the assertion if cmdline is a single token.) + let _ = s.contains(' '); + } + + #[test] + fn read_cmdline_handles_missing_pid() { + let cmdline = read_cmdline(999_999_999); + assert!(cmdline.is_none()); + } + + #[test] + fn read_io_priority_handles_self() { + // Self's io_priority is a u32. Some kernels may not + // expose it; tolerate that. The function must not + // panic. + let _ = read_io_priority(std::process::id()); + } + + #[test] + fn read_io_priority_handles_missing_pid() { + assert_eq!(read_io_priority(999_999_999), None); + } } diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index 0670e31e3f..9106391152 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -73,29 +73,68 @@ impl SortMode { } } pub fn sort(self, processes: &mut Vec) { - match self { - SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)), - SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)), - SortMode::Io => processes.sort_by(|a, b| { - let ai = a.io_total_kb(); - let bi = b.io_total_kb(); - match (ai, bi) { - (Some(x), Some(y)) => y.cmp(&x), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } - }), - SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb), - SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb), - SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()), - SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs), - SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs), - SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)), - SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)), - SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)), - SortMode::Pid => processes.sort_by_key(|p| p.pid), - SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)), + // v1.38: direction is fixed at descending. The app + // calls `sort_ascending(processes, true)` for ascending + // sorts. Default false keeps backward compatibility for + // existing callers (tests, sort_tree, etc). + self.sort_ascending(processes, false) + } + + /// Sort with explicit direction. `ascending = true` flips + /// the comparator for every sort mode. htop parity: the + /// `i` key toggles the App's `sort_ascending` flag and + /// re-sorts the visible processes. + pub fn sort_ascending(self, processes: &mut Vec, ascending: bool) { + if ascending { + match self { + SortMode::Rss => processes.sort_by(|a, b| a.rss_kb.cmp(&b.rss_kb)), + SortMode::Cpu => processes.sort_by(|a, b| a.cpu_pct.partial_cmp(&b.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)), + SortMode::Io => processes.sort_by(|a, b| { + let ai = a.io_total_kb(); + let bi = b.io_total_kb(); + match (ai, bi) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }), + SortMode::IoRead => sort_by_io_field_asc(processes, |p| p.io_read_kb), + SortMode::IoWrite => sort_by_io_field_asc(processes, |p| p.io_write_kb), + SortMode::IoRate => sort_by_io_rate_field_asc(processes, |p| p.io_total_rate_kbs()), + SortMode::IoReadRate => sort_by_io_rate_field_asc(processes, |p| p.io_read_rate_kbs), + SortMode::IoWriteRate => sort_by_io_rate_field_asc(processes, |p| p.io_write_rate_kbs), + SortMode::RChar => processes.sort_by(|a, b| a.io_rchar_kb.cmp(&b.io_rchar_kb)), + SortMode::WChar => processes.sort_by(|a, b| a.io_wchar_kb.cmp(&b.io_wchar_kb)), + SortMode::VSize => processes.sort_by(|a, b| a.vsize_kb.cmp(&b.vsize_kb)), + SortMode::Pid => processes.sort_by_key(|p| p.pid), + SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)), + } + } else { + match self { + SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)), + SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)), + SortMode::Io => processes.sort_by(|a, b| { + let ai = a.io_total_kb(); + let bi = b.io_total_kb(); + match (ai, bi) { + (Some(x), Some(y)) => y.cmp(&x), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }), + SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb), + SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb), + SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()), + SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs), + SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs), + SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)), + SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)), + SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)), + SortMode::Pid => processes.sort_by_key(|p| p.pid), + SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)), + } } } } @@ -116,6 +155,26 @@ where }); } +/// Ascending variant of `sort_by_io_field`. v1.38: paired +/// helpers (one for each direction) keep the per-direction +/// comparator logic local and avoid a runtime branch inside +/// the closure. +fn sort_by_io_field_asc(processes: &mut Vec, field: F) +where + F: Fn(&ProcessInfo) -> Option, +{ + processes.sort_by(|a, b| { + let ai = field(a); + let bi = field(b); + match (ai, bi) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); +} + fn sort_by_io_rate_field(processes: &mut Vec, field: F) where F: Fn(&ProcessInfo) -> Option, @@ -132,6 +191,22 @@ where }); } +fn sort_by_io_rate_field_asc(processes: &mut Vec, field: F) +where + F: Fn(&ProcessInfo) -> Option, +{ + processes.sort_by(|a, b| { + let ai = field(a); + let bi = field(b); + match (ai, bi) { + (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); +} + /// 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 @@ -1079,6 +1154,26 @@ mod io_sort_unit_tests { assert_eq!(ps[1].pid, 2); } + #[test] + fn sort_ascending_flips_rss_order() { + // v1.38: the same sort key produces opposite order + // when ascending is true. Rss sort: pid 1 (huge) first + // desc, pid 2 (small) first asc. + let mut ps = vec![ + ProcessInfo { pid: 1, rss_kb: 1000, ..Default::default() }, + ProcessInfo { pid: 2, rss_kb: 100, ..Default::default() }, + ProcessInfo { pid: 3, rss_kb: 500, ..Default::default() }, + ]; + SortMode::Rss.sort_ascending(&mut ps, false); + assert_eq!(ps[0].pid, 1); // 1000 (desc) + assert_eq!(ps[1].pid, 3); // 500 + assert_eq!(ps[2].pid, 2); // 100 + SortMode::Rss.sort_ascending(&mut ps, true); + assert_eq!(ps[0].pid, 2); // 100 (asc) + assert_eq!(ps[1].pid, 3); // 500 + assert_eq!(ps[2].pid, 1); // 1000 + } + #[test] fn sort_by_rchar_descending() { // RChar sort uses io_rchar_kb (VFS-level reads). diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index d03526eede..e7a070fde2 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -832,6 +832,22 @@ pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { disk.kind_label(), smart_badge ).set_style(theme::LABEL_BOLD))); + // v1.38: per-disk I/O throughput history sparkline. + // 12 samples, normalized 0..=255 against the disk's + // own max (same pattern as the per-PID IO-RATE column + // in the Process tab). + let disk_sparkline: String = app + .disk_history + .get(&disk.name) + .map(|hist| { + let bytes: Vec = hist.iter().copied().collect(); + io_rate_sparkline(&bytes) + }) + .unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN)); + lines.push(Line::from(format!( + " Throughput: {}", + disk_sparkline + ).set_style(theme::VALUE))); if let Some(model) = &disk.model { lines.push(Line::from(vec![ " Model: ".set_style(theme::LABEL), @@ -1165,6 +1181,23 @@ pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Par opt(&s.uid_real), opt(&s.uid_effective), opt(&s.uid_saved), opt(&s.gid_real), opt(&s.gid_effective), opt(&s.gid_saved) ).set_style(theme::VALUE))); + // v1.38: cmdline (htop parity). Long cmdlines are + // truncated to fit the popup; the full string is available + // via scrollback in a real terminal. + let cmdline = detail.cmdline.as_deref().unwrap_or("?"); + let cmdline_trunc: String = cmdline.chars().take(120).collect(); + lines.push(Line::from(format!( + " Cmdline: {}", + if cmdline_trunc.is_empty() { "?".to_string() } else { cmdline_trunc } + ).set_style(theme::VALUE))); + let io_pri = detail + .io_priority + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".to_string()); + lines.push(Line::from(format!( + " IO priority: {}", + io_pri + ).set_style(theme::VALUE))); lines.push(Line::from("")); lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD))); lines.push(Line::from(format!(