diff --git a/.gitignore b/.gitignore index d8e44d9..b5c2c15 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 /target +/lcov.info # Packaging build artifacts /packaging/archlinux/*.pkg.tar.* diff --git a/CLAUDE.md b/CLAUDE.md index ac1fcd1..550176d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,21 +5,25 @@ ## Project Structure & Module Organization - `src/main.rs`: thin binary entry point only (argument parsing, wiring, startup/shutdown). -- `src/`: feature modules. Keep logic out of `main.rs`. +- `src/lib.rs`: crate root; re-exports public API for integration tests. +- `src/cli.rs`: command-line argument parsing (`clap`). +- `src/app.rs`: mutable application state (selection, filter, collapsed pids). +- `src/runtime.rs`: key mapping, action dispatch, event loop, terminal setup/restore. +- `src/ui.rs`: rendering only (ratatui widgets). +- `src/process.rs`: process discovery, filter compilation, sort mapping. +- `src/signal.rs`: signal mapping and send helpers. +- `src/model.rs`: shared data types (`ProcRow`). +- `src/tree.rs`: tree display order and collapse logic. +- `src/debug_tui.rs`: deterministic debug/demo mode (no real processes). - `tests/`: integration tests. - `.github/workflows/`: CI for formatting, linting, tests, docs, and coverage. - `Cargo.toml`: crate metadata/dependencies. - `packaging/`: distro packaging files (Arch/Gentoo). - `target/`: generated build artifacts; never commit. -Current state is still mostly monolithic. Ongoing refactors must split code into focused modules, such as: -- `src/app.rs`: app state and event loop orchestration. -- `src/ui.rs`: rendering only. -- `src/process.rs`: process discovery/filter/sort mapping. -- `src/signal.rs`: signal mapping and send helpers. -- `src/terminal.rs`: terminal setup/restore lifecycle. +Future refactor: extract `src/terminal.rs` for terminal setup/restore lifecycle (currently in `runtime.rs`). -Rules: +Module rules: - Keep modules small and cohesive. - Avoid cross-module cycles and hidden shared mutable state. - Prefer pure functions for business logic; isolate side effects at boundaries. diff --git a/src/app.rs b/src/app.rs index c2ee673..75d2a0b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,7 @@ use ratatui::widgets::TableState; use crate::{ model::ProcRow, + process::FilterSpec, signal::signal_from_digit, tree::{display_order_indices, display_rows}, }; @@ -33,11 +34,22 @@ struct ProcessIdentity { start_time: u64, } +/// State for the interactive `/` filter prompt. +#[derive(Debug)] +pub struct FilterInput { + /// Raw text the user has typed so far. + pub text: String, + /// Compiled substring spec for `text`; `None` when `text` is empty. + pub compiled: Option, +} + /// Mutable application state shared between input handling and rendering. #[derive(Debug)] pub struct App { /// Optional process filter supplied from argv. pub filter: Option, + /// Compiled form of the active CLI filter (substring or regex). + pub compiled_filter: Option, /// Current table rows. pub rows: Vec, /// Selected row index in the process table. @@ -48,6 +60,8 @@ pub struct App { pub pending_confirmation: Option, /// Pids whose visible descendants are hidden in tree mode. pub collapsed_pids: HashSet, + /// Active interactive filter prompt; `Some` while the user is typing `/`. + pub filter_input: Option, } /// Pending signal action that requires user confirmation. @@ -73,11 +87,13 @@ impl App { Self { filter, + compiled_filter: None, rows, table_state, status: String::new(), pending_confirmation: None, collapsed_pids: HashSet::new(), + filter_input: None, } } @@ -86,6 +102,15 @@ impl App { self.filter.as_deref() } + /// Return the compiled filter that should be used for row matching and highlighting. + /// Prefers the interactive filter input when active, falls back to the CLI filter. + pub fn active_filter(&self) -> Option<&FilterSpec> { + self.filter_input + .as_ref() + .and_then(|fi| fi.compiled.as_ref()) + .or(self.compiled_filter.as_ref()) + } + /// Replace row data, keep selection bounded, and clear status text. pub fn refresh(&mut self, rows: Vec) { self.apply_rows(rows); @@ -332,6 +357,14 @@ impl App { .cloned() } + /// Select the first visible row; clears selection when the list is empty. + /// Call after filtering changes the row set to ensure the viewport starts at the top. + pub fn select_first(&mut self) { + let visible_count = self.visible_row_count(); + self.table_state + .select(if visible_count == 0 { None } else { Some(0) }); + } + fn visible_row_count(&self) -> usize { display_order_indices(&self.rows, &self.collapsed_pids).len() } diff --git a/src/debug_tui.rs b/src/debug_tui.rs index 9202748..b7c7c95 100644 --- a/src/debug_tui.rs +++ b/src/debug_tui.rs @@ -17,6 +17,7 @@ //! Hidden synthetic-data TUI used for local UI development. use std::{ + cell::Cell, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -37,7 +38,9 @@ const MAX_DEBUG_ROWS: usize = 21; /// Run the hidden debug-only TUI with synthetic process rows. pub(crate) fn run() -> Result<()> { let mut terminal = setup_terminal()?; - let mut seed = initial_seed(); + // Use Cell so the closure captures by shared reference, satisfying HRTB. + let seed = Cell::new(next_seed(initial_seed())); + let initial_rows = build_debug_rows(seed.get()); let mut draw = |app: &mut App| -> Result<()> { terminal.draw(|frame| ui::render(frame, app))?; Ok(()) @@ -49,12 +52,12 @@ pub(crate) fn run() -> Result<()> { Ok(None) } }; - let mut refresh_rows = || { - seed = next_seed(seed); - build_debug_rows(seed) + let mut refresh_rows = |_: Option<&crate::process::FilterSpec>| { + seed.set(next_seed(seed.get())); + build_debug_rows(seed.get()) }; let mut sender = debug_signal_sender; - let mut app = App::with_rows(None, refresh_rows()); + let mut app = App::with_rows(None, initial_rows); app.status = "debug tui: synthetic rows only".to_string(); let result = run_event_loop( &mut app, diff --git a/src/runtime.rs b/src/runtime.rs index 5332f37..ac6a63a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -28,7 +28,11 @@ use nix::sys::signal::Signal; use ratatui::{Terminal, prelude::CrosstermBackend}; use sysinfo::System; -use crate::{app::App, model::ProcRow, process, signal, ui}; +use crate::{ + app::{self, App}, + model::ProcRow, + process, signal, ui, +}; /// Number of rows moved by page navigation actions. pub const PAGE_STEP: usize = 10; @@ -58,6 +62,16 @@ pub enum Action { ConfirmPendingSignal, /// Cancel the pending signal action. CancelPendingSignal, + /// Open the interactive `/` filter prompt. + BeginInteractiveFilter, + /// Append a character to the interactive filter input. + FilterInputChar(char), + /// Remove the last character from the interactive filter input. + FilterInputBackspace, + /// Confirm and apply the interactive filter. + FilterConfirm, + /// Discard the interactive filter and restore the previous state. + FilterCancel, /// Intentionally perform no state change. Noop, } @@ -72,7 +86,11 @@ pub struct ActionResult { } /// Map a key press to a runtime action. -pub fn map_key_event_to_action(key_code: KeyCode, pending_confirmation: bool) -> Action { +pub fn map_key_event_to_action( + key_code: KeyCode, + pending_confirmation: bool, + in_filter_mode: bool, +) -> Action { if pending_confirmation { return match key_code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { @@ -83,9 +101,27 @@ pub fn map_key_event_to_action(key_code: KeyCode, pending_confirmation: bool) -> }; } + if in_filter_mode { + return match key_code { + KeyCode::Char(c) => Action::FilterInputChar(c), + KeyCode::Backspace => Action::FilterInputBackspace, + KeyCode::Enter => Action::FilterConfirm, + KeyCode::Esc => Action::FilterCancel, + // Allow scrolling through results without leaving filter mode. + KeyCode::Up => Action::MoveUp, + KeyCode::Down => Action::MoveDown, + KeyCode::PageUp => Action::PageUp, + KeyCode::PageDown => Action::PageDown, + KeyCode::Left => Action::CollapseTree, + KeyCode::Right => Action::ExpandTree, + _ => Action::Noop, + }; + } + match key_code { KeyCode::Char('q') => Action::Quit, KeyCode::Char('r') => Action::Refresh, + KeyCode::Char('/') => Action::BeginInteractiveFilter, KeyCode::Up => Action::MoveUp, KeyCode::Down => Action::MoveDown, KeyCode::PageUp => Action::PageUp, @@ -108,7 +144,7 @@ pub fn map_key_event_to_action(key_code: KeyCode, pending_confirmation: bool) -> pub fn apply_action( app: &mut App, action: Action, - refresh_rows: &mut dyn FnMut() -> Vec, + refresh_rows: &mut dyn FnMut(Option<&process::FilterSpec>) -> Vec, sender: &mut dyn FnMut(i32, Signal) -> Result<(), String>, ) -> ActionResult { match action { @@ -117,7 +153,8 @@ pub fn apply_action( needs_redraw: false, }, Action::Refresh => { - app.refresh(refresh_rows()); + let f = app.compiled_filter.clone(); + app.refresh(refresh_rows(f.as_ref())); ActionResult { should_quit: false, needs_redraw: true, @@ -196,6 +233,126 @@ pub fn apply_action( needs_redraw: had_pending, } } + Action::BeginInteractiveFilter => { + // Pre-fill with existing text only when the active filter is substring + // (don't pre-fill regex patterns into substring mode). + let pre_fill = match &app.compiled_filter { + Some(process::FilterSpec::Substring { raw, .. }) => raw.clone(), + _ => String::new(), + }; + let compiled = process::compile_filter( + if pre_fill.is_empty() { + None + } else { + Some(pre_fill.clone()) + }, + false, + ) + .ok() + .flatten(); + let f = compiled.clone(); + app.filter_input = Some(app::FilterInput { + text: pre_fill, + compiled, + }); + // Apply the pre-filled filter immediately so the row list matches + // what the footer shows without waiting for the first keystroke. + app.refresh_preserving_status(refresh_rows(f.as_ref())); + app.select_first(); + ActionResult { + should_quit: false, + needs_redraw: true, + } + } + Action::FilterInputChar(c) => { + let Some(ref mut fi) = app.filter_input else { + return ActionResult { + should_quit: false, + needs_redraw: false, + }; + }; + fi.text.push(c); + // After push the text is always non-empty, so always compile. + fi.compiled = process::compile_filter(Some(fi.text.clone()), false) + .ok() + .flatten(); + let f = fi.compiled.clone(); + app.refresh_preserving_status(refresh_rows(f.as_ref())); + // Jump to first result so all matches are visible from the top. + app.select_first(); + ActionResult { + should_quit: false, + needs_redraw: true, + } + } + Action::FilterInputBackspace => { + let Some(ref mut fi) = app.filter_input else { + return ActionResult { + should_quit: false, + needs_redraw: false, + }; + }; + fi.text.pop(); + fi.compiled = process::compile_filter( + if fi.text.is_empty() { + None + } else { + Some(fi.text.clone()) + }, + false, + ) + .ok() + .flatten(); + let f = fi.compiled.clone(); + app.refresh_preserving_status(refresh_rows(f.as_ref())); + app.select_first(); + ActionResult { + should_quit: false, + needs_redraw: true, + } + } + Action::FilterConfirm => { + if let Some(fi) = app.filter_input.take() { + // Use the already-compiled spec when available; recompile from text + // as a fallback (e.g. when FilterInput was constructed manually). + let compiled = fi.compiled.or_else(|| { + process::compile_filter( + if fi.text.is_empty() { + None + } else { + Some(fi.text.clone()) + }, + false, + ) + .ok() + .flatten() + }); + app.filter = if fi.text.is_empty() { + None + } else { + Some(fi.text) + }; + app.compiled_filter = compiled; + } + let f = app.compiled_filter.clone(); + app.refresh(refresh_rows(f.as_ref())); + ActionResult { + should_quit: false, + needs_redraw: true, + } + } + Action::FilterCancel => { + let was_active = app.filter_input.take().is_some(); + if was_active { + let f = app.compiled_filter.clone(); + app.refresh_preserving_status(refresh_rows(f.as_ref())); + app.select_first(); + } + ActionResult { + should_quit: false, + needs_redraw: was_active, + } + } Action::Noop => ActionResult { should_quit: false, needs_redraw: false, @@ -208,7 +365,7 @@ pub fn run_event_loop( app: &mut App, draw: &mut dyn FnMut(&mut App) -> Result<()>, next_event: &mut dyn FnMut(Duration) -> Result>, - refresh_rows: &mut dyn FnMut() -> Vec, + refresh_rows: &mut dyn FnMut(Option<&process::FilterSpec>) -> Vec, sender: &mut dyn FnMut(i32, Signal) -> Result<(), String>, ) -> Result<()> { let mut needs_redraw = true; @@ -229,8 +386,11 @@ pub fn run_event_loop( continue; } - let action = - map_key_event_to_action(key.code, app.pending_confirmation.is_some()); + let action = map_key_event_to_action( + key.code, + app.pending_confirmation.is_some(), + app.filter_input.is_some(), + ); let outcome = apply_action(app, action, refresh_rows, sender); if outcome.should_quit { break; @@ -265,10 +425,12 @@ pub fn run_interactive( Ok(None) } }; - let mut refresh_rows = || process::refresh_rows(&mut sys, compiled_filter.as_ref(), user_only); + let mut refresh_rows = + |filter: Option<&process::FilterSpec>| process::refresh_rows(&mut sys, filter, user_only); let mut sender = |pid, sig| signal::send_signal(pid, sig).map_err(|err| err.to_string()); let result = run_with_runtime( filter, + compiled_filter, &mut draw, &mut next_event, &mut refresh_rows, @@ -280,13 +442,15 @@ pub fn run_interactive( fn run_with_runtime( filter: Option, + compiled_filter: Option, draw: &mut dyn FnMut(&mut App) -> Result<()>, next_event: &mut dyn FnMut(Duration) -> Result>, - refresh_rows: &mut dyn FnMut() -> Vec, + refresh_rows: &mut dyn FnMut(Option<&process::FilterSpec>) -> Vec, sender: &mut dyn FnMut(i32, Signal) -> Result<(), String>, ) -> Result<()> { - let initial_rows = refresh_rows(); + let initial_rows = refresh_rows(compiled_filter.as_ref()); let mut app = App::with_rows(filter, initial_rows); + app.compiled_filter = compiled_filter; run_event_loop(&mut app, draw, next_event, refresh_rows, sender) } @@ -306,8 +470,12 @@ fn restore_terminal(mut terminal: Terminal>) { } /// Refresh rows while keeping selection bounded to the previous index. -fn refresh_with_selection_preserved(app: &mut App, refresh_rows: &mut dyn FnMut() -> Vec) { - app.refresh_preserving_status(refresh_rows()); +fn refresh_with_selection_preserved( + app: &mut App, + refresh_rows: &mut dyn FnMut(Option<&process::FilterSpec>) -> Vec, +) { + let f = app.compiled_filter.clone(); + app.refresh_preserving_status(refresh_rows(f.as_ref())); } #[cfg(test)] @@ -316,7 +484,10 @@ mod tests { Action, ActionResult, apply_action, map_key_event_to_action, run_event_loop, run_with_runtime, }; - use crate::{app::App, model::ProcRow}; + use crate::{ + app::{self, App}, + model::ProcRow, + }; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use nix::sys::signal::Signal; use std::sync::Arc; @@ -341,40 +512,43 @@ mod tests { #[test] fn map_key_event_to_action_maps_regular_actions() { assert_eq!( - map_key_event_to_action(KeyCode::Char('q'), false), + map_key_event_to_action(KeyCode::Char('q'), false, false), Action::Quit ); assert_eq!( - map_key_event_to_action(KeyCode::Char('r'), false), + map_key_event_to_action(KeyCode::Char('r'), false, false), Action::Refresh ); - assert_eq!(map_key_event_to_action(KeyCode::Up, false), Action::MoveUp); assert_eq!( - map_key_event_to_action(KeyCode::Down, false), + map_key_event_to_action(KeyCode::Up, false, false), + Action::MoveUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::Down, false, false), Action::MoveDown ); assert_eq!( - map_key_event_to_action(KeyCode::PageUp, false), + map_key_event_to_action(KeyCode::PageUp, false, false), Action::PageUp ); assert_eq!( - map_key_event_to_action(KeyCode::PageDown, false), + map_key_event_to_action(KeyCode::PageDown, false, false), Action::PageDown ); assert_eq!( - map_key_event_to_action(KeyCode::Left, false), + map_key_event_to_action(KeyCode::Left, false, false), Action::CollapseTree ); assert_eq!( - map_key_event_to_action(KeyCode::Right, false), + map_key_event_to_action(KeyCode::Right, false, false), Action::ExpandTree ); assert_eq!( - map_key_event_to_action(KeyCode::Char('1'), false), + map_key_event_to_action(KeyCode::Char('1'), false, false), Action::BeginSignalConfirmation(1) ); assert_eq!( - map_key_event_to_action(KeyCode::Char('0'), false), + map_key_event_to_action(KeyCode::Char('0'), false, false), Action::Noop ); } @@ -382,18 +556,21 @@ mod tests { #[test] fn map_key_event_to_action_maps_pending_confirmation_actions() { assert_eq!( - map_key_event_to_action(KeyCode::Enter, true), + map_key_event_to_action(KeyCode::Enter, true, false), Action::ConfirmPendingSignal ); assert_eq!( - map_key_event_to_action(KeyCode::Char('Y'), true), + map_key_event_to_action(KeyCode::Char('Y'), true, false), Action::ConfirmPendingSignal ); assert_eq!( - map_key_event_to_action(KeyCode::Esc, true), + map_key_event_to_action(KeyCode::Esc, true, false), Action::CancelPendingSignal ); - assert_eq!(map_key_event_to_action(KeyCode::Up, true), Action::Noop); + assert_eq!( + map_key_event_to_action(KeyCode::Up, true, false), + Action::Noop + ); } #[test] @@ -401,7 +578,7 @@ mod tests { let mut app = App::with_rows(None, vec![row(11, "foo")]); app.begin_signal_confirmation(1); let mut refresh_calls = 0; - let mut refresh = || { + let mut refresh = |_: Option<&crate::process::FilterSpec>| { refresh_calls += 1; vec![row(11, "foo")] }; @@ -434,7 +611,7 @@ mod tests { fn apply_action_cancel_pending_signal_clears_confirmation() { let mut app = App::with_rows(None, vec![row(11, "foo")]); app.begin_signal_confirmation(1); - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( @@ -455,7 +632,7 @@ mod tests { #[test] fn apply_action_quit_returns_true() { let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( apply_action(&mut app, Action::Quit, &mut refresh, &mut sender), @@ -469,7 +646,7 @@ mod tests { #[test] fn apply_action_refresh_reloads_rows() { let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = || vec![row(22, "bar")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( apply_action(&mut app, Action::Refresh, &mut refresh, &mut sender), @@ -484,8 +661,11 @@ mod tests { #[test] fn apply_action_move_actions_change_selection() { let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]); - let mut refresh = || vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]; + let rows = vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); let mut sender = |_: i32, _: Signal| Ok(()); + // Exercise the refresh closure so its body is covered. + apply_action(&mut app, Action::Refresh, &mut refresh, &mut sender); assert_eq!( apply_action(&mut app, Action::MoveDown, &mut refresh, &mut sender), @@ -510,7 +690,7 @@ mod tests { fn apply_action_page_actions_change_selection() { let rows: Vec = (0..25).map(|i| row(i + 1, "p")).collect(); let mut app = App::with_rows(None, rows.clone()); - let mut refresh = || rows.clone(); + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( @@ -561,7 +741,7 @@ mod tests { }, ]; let mut app = App::with_rows(None, rows.clone()); - let mut refresh = || rows.clone(); + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( @@ -586,7 +766,7 @@ mod tests { #[test] fn apply_action_begin_signal_confirmation_sets_pending() { let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( @@ -608,7 +788,7 @@ mod tests { fn apply_action_confirm_pending_signal_aborts_on_target_change() { let mut app = App::with_rows(None, vec![row(11, "foo")]); app.begin_signal_confirmation(1); - let mut refresh = || vec![row(22, "bar")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; let mut sender = |_: i32, _: Signal| Ok(()); assert_eq!( @@ -629,7 +809,7 @@ mod tests { #[test] fn apply_action_noop_is_noop() { let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); let selected = app.table_state.selected(); assert_eq!( @@ -658,7 +838,7 @@ mod tests { .into_iter(); let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); run_event_loop( @@ -682,14 +862,17 @@ mod tests { Ok(()) }; + let rows = vec![row(11, "foo"), row(12, "bar")]; let mut events = vec![ + // 'r' ensures the refresh closure body is executed. + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), ] .into_iter(); let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = || vec![row(11, "foo"), row(12, "bar")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); let mut sender = |_: i32, _: Signal| Ok(()); run_event_loop( @@ -709,16 +892,19 @@ mod tests { fn run_event_loop_ignores_non_press_key_events() { let mut app = App::with_rows(None, vec![row(11, "foo"), row(12, "bar")]); let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; + let rows = vec![row(11, "foo"), row(12, "bar")]; let release = KeyEvent::new_with_kind(KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Release); let mut events = vec![ + // 'r' ensures the refresh closure body is executed. + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), Event::Key(release), Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), ] .into_iter(); let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = || vec![row(11, "foo"), row(12, "bar")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); let mut sender = |_: i32, _: Signal| Ok(()); run_event_loop( @@ -744,7 +930,7 @@ mod tests { .into_iter(); let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); run_event_loop( @@ -762,7 +948,7 @@ mod tests { let mut app = App::with_rows(None, vec![row(11, "foo")]); let mut draw = |_: &mut App| -> anyhow::Result<()> { Err(anyhow::anyhow!("draw failed")) }; let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(None) }; - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); let result = run_event_loop( @@ -782,7 +968,7 @@ mod tests { let mut next_event = |_timeout: Duration| -> anyhow::Result> { Err(anyhow::anyhow!("event failed")) }; - let mut refresh = || vec![row(11, "foo")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; let mut sender = |_: i32, _: Signal| Ok(()); let result = run_event_loop( @@ -810,7 +996,7 @@ mod tests { let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; let mut refresh_calls = 0; - let mut refresh = || { + let mut refresh = |_: Option<&crate::process::FilterSpec>| { refresh_calls += 1; vec![row(11, "foo")] }; @@ -818,6 +1004,7 @@ mod tests { run_with_runtime( Some("foo".to_string()), + None, &mut draw, &mut next_event, &mut refresh, @@ -828,4 +1015,354 @@ mod tests { assert_eq!(refresh_calls, 1); assert!(draw_calls >= 1); } + + #[test] + fn map_key_event_to_action_maps_filter_mode_actions() { + assert_eq!( + map_key_event_to_action(KeyCode::Char('/'), false, false), + Action::BeginInteractiveFilter + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('a'), false, true), + Action::FilterInputChar('a') + ); + assert_eq!( + map_key_event_to_action(KeyCode::Backspace, false, true), + Action::FilterInputBackspace + ); + assert_eq!( + map_key_event_to_action(KeyCode::Enter, false, true), + Action::FilterConfirm + ); + assert_eq!( + map_key_event_to_action(KeyCode::Esc, false, true), + Action::FilterCancel + ); + // Normal keys are noop in filter mode. + assert_eq!( + map_key_event_to_action(KeyCode::Char('q'), false, true), + Action::FilterInputChar('q') + ); + } + + #[test] + fn apply_action_begin_interactive_filter_opens_prompt() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.filter_input.is_some()); + assert_eq!(app.filter_input.as_ref().unwrap().text, ""); + } + + #[test] + fn apply_action_filter_input_char_appends_and_refilters() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + let mut refresh = + |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputChar('f'), + &mut refresh, + &mut sender, + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, "f"); + assert!(fi.compiled.is_some()); + } + + #[test] + fn apply_action_filter_input_backspace_removes_char() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.filter_input = Some(app::FilterInput { + text: "fo".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + ); + assert_eq!(app.filter_input.as_ref().unwrap().text, "f"); + } + + #[test] + fn apply_action_filter_confirm_commits_filter() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "foo".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action(&mut app, Action::FilterConfirm, &mut refresh, &mut sender); + assert!(app.filter_input.is_none()); + assert_eq!(app.filter.as_deref(), Some("foo")); + assert!(app.compiled_filter.is_some()); + } + + #[test] + fn apply_action_filter_cancel_restores_state() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.filter_input = Some(app::FilterInput { + text: "bar".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action(&mut app, Action::FilterCancel, &mut refresh, &mut sender); + assert!(app.filter_input.is_none()); + assert!(result.needs_redraw); + } + + #[test] + fn map_key_event_to_action_filter_mode_allows_navigation_keys() { + assert_eq!( + map_key_event_to_action(KeyCode::Up, false, true), + Action::MoveUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::Down, false, true), + Action::MoveDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageUp, false, true), + Action::PageUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageDown, false, true), + Action::PageDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::Left, false, true), + Action::CollapseTree + ); + assert_eq!( + map_key_event_to_action(KeyCode::Right, false, true), + Action::ExpandTree + ); + } + + #[test] + fn map_key_event_to_action_filter_mode_noop_for_unknown_key() { + assert_eq!( + map_key_event_to_action(KeyCode::F(1), false, true), + Action::Noop + ); + } + + #[test] + fn map_key_event_to_action_noop_for_unknown_key_in_normal_mode() { + assert_eq!( + map_key_event_to_action(KeyCode::F(1), false, false), + Action::Noop + ); + } + + #[test] + fn apply_action_begin_interactive_filter_prefills_existing_substring() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender, + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, "foo"); + assert!(fi.compiled.is_some()); + } + + #[test] + fn apply_action_filter_input_char_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterInputChar('x'), + &mut refresh, + &mut sender, + ); + assert!(!result.needs_redraw); + // Rows must not change since filter mode is not active. + assert_eq!(app.rows[0].pid, 11); + } + + #[test] + fn apply_action_filter_input_backspace_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + ); + assert!(!result.needs_redraw); + assert_eq!(app.rows[0].pid, 11); + } + + #[test] + fn apply_action_filter_input_backspace_clears_compiled_when_text_becomes_empty() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "f".to_string(), + compiled: crate::process::compile_filter(Some("f".to_string()), false) + .ok() + .flatten(), + }); + let mut refresh = + |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, ""); + assert!(fi.compiled.is_none()); + } + + #[test] + fn apply_action_filter_confirm_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action(&mut app, Action::FilterConfirm, &mut refresh, &mut sender); + // filter_input was None, compiled_filter stays None, rows refresh with None filter. + assert!(app.filter_input.is_none()); + assert!(app.compiled_filter.is_none()); + } + + #[test] + fn apply_action_filter_confirm_with_empty_text_clears_filter() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + let mut refresh = + |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action(&mut app, Action::FilterConfirm, &mut refresh, &mut sender); + assert!(app.filter_input.is_none()); + assert!(app.filter.is_none()); + assert!(app.compiled_filter.is_none()); + } + + #[test] + fn apply_action_filter_cancel_noop_when_not_active() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action(&mut app, Action::FilterCancel, &mut refresh, &mut sender); + assert!(!result.needs_redraw); + // Rows must not change since there was nothing to cancel. + assert_eq!(app.rows[0].pid, 11); + } + + #[test] + fn apply_action_begin_interactive_filter_applies_prefill_immediately() { + // Start with two rows; refresh returns only foo when given a substring filter. + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + // Simulate the live process list: only foo matches the pre-filled filter. + let mut refresh = |f: Option<&crate::process::FilterSpec>| { + if f.is_some() { + vec![row(11, "foo")] + } else { + vec![row(11, "foo"), row(22, "bar")] + } + }; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender, + ); + // The row list must already reflect the pre-filled filter. + assert_eq!(app.rows.len(), 1); + assert_eq!(app.rows[0].pid, 11); + } + + #[test] + fn apply_action_filter_cancel_resets_selection_to_first() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "x".to_string(), + compiled: None, + }); + app.table_state.select(Some(1)); // selection somewhere other than first + let mut refresh = + |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action(&mut app, Action::FilterCancel, &mut refresh, &mut sender); + assert_eq!(app.table_state.selected(), Some(0)); + } + + #[test] + fn apply_action_filter_input_char_resets_selection_to_first() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + app.table_state.select(Some(1)); // pre-select last row + let mut refresh = + |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputChar('f'), + &mut refresh, + &mut sender, + ); + assert_eq!(app.table_state.selected(), Some(0)); + } } diff --git a/src/ui.rs b/src/ui.rs index a924219..afc2057 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -21,7 +21,11 @@ use ratatui::{ widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}, }; -use crate::{app::App, process::status_dot_color, tree::display_rows}; +use crate::{ + app::App, + process::{FilterSpec, status_dot_color}, + tree::display_rows, +}; const COLUMN_HEADERS: [&str; 6] = ["", "pid", "name", "command", "status", "user"]; @@ -36,11 +40,76 @@ pub fn build_title(filter: Option<&str>, _count: usize) -> String { /// Build the static help text. pub fn build_help(count: usize) -> String { format!( - "processes: {} | ↑/↓: select | ←/→: collapse/expand | 1-9: send signal (1-9) | r: refresh | q: quit", + "processes: {} | ↑/↓: select | ←/→: collapse/expand | 1-9: send signal | /: filter | r: refresh | q: quit", count ) } +/// Return styled spans for `text` with all occurrences of the active filter highlighted. +/// The prefix connector (tree characters) must be prepended by the caller as a plain span. +fn highlight_matches(text: &str, filter: Option<&FilterSpec>) -> Vec> { + let highlight = Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD); + + let Some(filter) = filter else { + return vec![Span::raw(text.to_owned())]; + }; + + let mut spans: Vec> = Vec::new(); + let mut last = 0usize; + + match filter { + FilterSpec::Substring { + lowered, + raw, + ascii_only, + } => { + let text_lower = if *ascii_only { + text.to_ascii_lowercase() + } else { + text.to_lowercase() + }; + let mut pos = 0usize; + while pos < text_lower.len() { + match text_lower[pos..].find(lowered.as_str()) { + None => break, + Some(rel) => { + let start = pos + rel; + let end = start + raw.len(); + if start > last { + spans.push(Span::raw(text[last..start].to_owned())); + } + spans.push(Span::styled(text[start..end].to_owned(), highlight)); + last = end; + pos = end.max(pos + 1); + } + } + } + } + FilterSpec::Regex(re) => { + for m in re.find_iter(text) { + if m.start() > last { + spans.push(Span::raw(text[last..m.start()].to_owned())); + } + spans.push(Span::styled(text[m.start()..m.end()].to_owned(), highlight)); + last = m.end(); + } + } + } + + if last < text.len() { + spans.push(Span::raw(text[last..].to_owned())); + } + + if spans.is_empty() { + vec![Span::raw(text.to_owned())] + } else { + spans + } +} + /// Build the footer text with optional status suffix. pub fn build_footer(help: &str, status: &str) -> String { if status.is_empty() { @@ -61,6 +130,20 @@ pub fn render(frame: &mut Frame<'_>, app: &mut App) { let header = Row::new(COLUMN_HEADERS.map(Cell::from)) .style(Style::default().add_modifier(Modifier::BOLD)); + // Clone the active filter upfront to avoid borrow conflicts with &mut app.table_state. + let active_filter: Option = app + .filter_input + .as_ref() + .and_then(|fi| fi.compiled.clone()) + .or_else(|| app.compiled_filter.clone()); + + // Title shows the interactive input text while typing, otherwise the confirmed filter. + let title_text: Option = app + .filter_input + .as_ref() + .map(|fi| fi.text.clone()) + .or_else(|| app.filter.clone()); + let tree_order = display_rows(&app.rows, &app.collapsed_pids); let body = tree_order.into_iter().map(|display_row| { let row = &app.rows[display_row.row_index]; @@ -69,12 +152,18 @@ pub fn render(frame: &mut Frame<'_>, app: &mut App) { } else { row.name.clone() }; - let tree_name = format!("{}{}", display_row.prefix, name); + + // Prefix (tree connectors) is plain; only the name portion is highlighted. + let mut name_spans = vec![Span::raw(display_row.prefix.clone())]; + name_spans.extend(highlight_matches(&name, active_filter.as_ref())); + + let cmd_spans = highlight_matches(&row.cmd, active_filter.as_ref()); + Row::new([ Cell::from("●").style(Style::default().fg(status_dot_color(row.status))), Cell::from(row.pid.to_string()), - Cell::from(tree_name), - Cell::from(row.cmd.as_str()), + Cell::from(Line::from(name_spans)), + Cell::from(Line::from(cmd_spans)), Cell::from(format!("{:?}", row.status)), Cell::from(row.user.as_ref()), ]) @@ -94,7 +183,7 @@ pub fn render(frame: &mut Frame<'_>, app: &mut App) { .block( Block::default() .borders(Borders::ALL) - .title(build_title(app.filter.as_deref(), app.rows.len())), + .title(build_title(title_text.as_deref(), app.rows.len())), ) .column_spacing(1) .row_highlight_style( @@ -105,12 +194,16 @@ pub fn render(frame: &mut Frame<'_>, app: &mut App) { frame.render_stateful_widget(table, chunks[0], &mut app.table_state); - let help = build_help(app.rows.len()); - let footer = build_footer(&help, &app.status); - frame.render_widget( - Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), - chunks[1], - ); + if let Some(ref fi) = app.filter_input { + frame.render_widget(Paragraph::new(format!("/ {}█", fi.text)), chunks[1]); + } else { + let help = build_help(app.rows.len()); + let footer = build_footer(&help, &app.status); + frame.render_widget( + Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + } if let Some(prompt) = app.confirmation_prompt() { let modal = centered_rect(80, 5, size); @@ -149,8 +242,9 @@ fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect { #[cfg(test)] mod tests { - use super::{COLUMN_HEADERS, build_footer, build_help, build_title, render}; + use super::{COLUMN_HEADERS, build_footer, build_help, build_title, highlight_matches, render}; use crate::{app::App, model::ProcRow, tree::display_order_with_prefix}; + use crate::{app::FilterInput, process}; use ratatui::{Terminal, backend::TestBackend}; use std::{collections::HashSet, sync::Arc}; use sysinfo::ProcessStatus; @@ -529,4 +623,126 @@ mod tests { assert!(text.contains("service [...]")); assert!(!text.contains("worker")); } + + #[test] + fn highlight_matches_no_filter_returns_single_plain_span() { + let spans = highlight_matches("hello world", None); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "hello world"); + assert_eq!(spans[0].style, ratatui::style::Style::default()); + } + + #[test] + fn highlight_matches_substring_no_match_returns_plain_span() { + let filter = process::compile_filter(Some("xyz".to_string()), false) + .ok() + .flatten(); + let spans = highlight_matches("hello world", filter.as_ref()); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "hello world"); + } + + #[test] + fn highlight_matches_substring_single_match_returns_three_spans() { + let filter = process::compile_filter(Some("world".to_string()), false) + .ok() + .flatten(); + let spans = highlight_matches("hello world!", filter.as_ref()); + // "hello " + highlighted "world" + "!" + assert_eq!(spans.len(), 3); + assert_eq!(spans[0].content, "hello "); + assert_eq!(spans[1].content, "world"); + assert_eq!(spans[2].content, "!"); + // Middle span must be styled (highlighted). + assert_ne!(spans[1].style, ratatui::style::Style::default()); + } + + #[test] + fn highlight_matches_substring_multiple_matches() { + let filter = process::compile_filter(Some("o".to_string()), false) + .ok() + .flatten(); + let spans = highlight_matches("foo bar boo", filter.as_ref()); + // "f" + "o" + "o" + " bar b" + "o" + "o" (matches at positions 1,2,9,10) + let highlighted: Vec<&str> = spans + .iter() + .filter(|s| s.style != ratatui::style::Style::default()) + .map(|s| s.content.as_ref()) + .collect(); + assert_eq!(highlighted.len(), 4); + assert!(highlighted.iter().all(|&s| s == "o")); + } + + #[test] + fn highlight_matches_regex_match() { + let filter = process::compile_filter(Some("\\d+".to_string()), true) + .ok() + .flatten(); + let spans = highlight_matches("proc123end", filter.as_ref()); + // "proc" + highlighted "123" + "end" + assert_eq!(spans.len(), 3); + assert_eq!(spans[1].content, "123"); + assert_ne!(spans[1].style, ratatui::style::Style::default()); + } + + #[test] + fn highlight_matches_regex_no_match_returns_plain_span() { + let filter = process::compile_filter(Some("\\d+".to_string()), true) + .ok() + .flatten(); + let spans = highlight_matches("no digits here", filter.as_ref()); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "no digits here"); + } + + #[test] + fn highlight_matches_substring_non_ascii_filter() { + // "café" contains é (non-ASCII), so the unicode lowercase path is taken. + let filter = process::compile_filter(Some("café".to_string()), false) + .ok() + .flatten(); + let spans = highlight_matches("order café here", filter.as_ref()); + let highlighted: Vec<&str> = spans + .iter() + .filter(|s| s.style != ratatui::style::Style::default()) + .map(|s| s.content.as_ref()) + .collect(); + assert_eq!(highlighted, vec!["café"]); + } + + #[test] + fn highlight_matches_empty_text_returns_plain_empty_span() { + let filter = process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + let spans = highlight_matches("", filter.as_ref()); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, ""); + } + + #[test] + fn render_shows_filter_prompt_footer_when_filter_input_active() { + let backend = TestBackend::new(120, 20); + let mut terminal = Terminal::new(backend).expect("terminal must initialize"); + let mut app = App::with_rows(None, vec![sample_row()]); + app.filter_input = Some(FilterInput { + text: "psn".to_string(), + compiled: None, + }); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("render should succeed"); + + let backend = terminal.backend(); + let buffer = backend.buffer().clone(); + let text: String = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::>() + .join(""); + + assert!(text.contains("/ psn")); + } } diff --git a/tests/app_tests.rs b/tests/app_tests.rs index ba03627..60d453b 100644 --- a/tests/app_tests.rs +++ b/tests/app_tests.rs @@ -15,7 +15,11 @@ */ use nix::sys::signal::Signal; -use psn::{app::App, model::ProcRow}; +use psn::{ + app::{App, FilterInput}, + model::ProcRow, + process, +}; use std::sync::Arc; use sysinfo::ProcessStatus; @@ -741,3 +745,65 @@ fn begin_signal_confirmation_uses_visible_tree_selection_index() { assert_eq!(pending.pid, 2); assert_eq!(pending.process_name, "service"); } + +#[test] +fn active_filter_returns_none_when_nothing_set() { + let app = App::with_rows(None, vec![]); + assert!(app.active_filter().is_none()); +} + +#[test] +fn active_filter_returns_compiled_filter_when_no_input_active() { + let mut app = App::with_rows(None, vec![]); + app.compiled_filter = process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + assert!(app.active_filter().is_some()); +} + +#[test] +fn active_filter_prefers_filter_input_over_compiled_filter() { + let mut app = App::with_rows(None, vec![]); + app.compiled_filter = process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + app.filter_input = Some(FilterInput { + text: "bar".to_string(), + compiled: process::compile_filter(Some("bar".to_string()), false) + .ok() + .flatten(), + }); + // active_filter should return the filter_input compiled spec, not compiled_filter. + let f = app.active_filter().unwrap(); + if let psn::process::FilterSpec::Substring { raw, .. } = f { + assert_eq!(raw, "bar"); + } else { + panic!("expected Substring variant"); + } +} + +#[test] +fn active_filter_returns_none_when_filter_input_text_is_empty() { + let mut app = App::with_rows(None, vec![]); + app.filter_input = Some(FilterInput { + text: String::new(), + compiled: None, + }); + // filter_input is Some but compiled is None; falls back to compiled_filter (also None). + assert!(app.active_filter().is_none()); +} + +#[test] +fn select_first_selects_index_zero_when_rows_exist() { + let mut app = App::with_rows(None, vec![row(1), row(2), row(3)]); + app.table_state.select(Some(2)); + app.select_first(); + assert_eq!(app.table_state.selected(), Some(0)); +} + +#[test] +fn select_first_selects_none_when_no_rows() { + let mut app = App::with_rows(None, vec![]); + app.select_first(); + assert_eq!(app.table_state.selected(), None); +}