From e9e96b169e3c04475bd20927d7d45bf807404b58 Mon Sep 17 00:00:00 2001 From: feiyang Date: Tue, 27 Jan 2026 19:36:51 +0000 Subject: [PATCH] Add a landing page to discover python processes and move the sampler to a contained subprocess such that exiting back to landing page abort all threads spawned by pyspy. receiving bincode messages can now be async ipc --- .cargo/config.toml | 2 + Cargo.lock | 235 +++++++++++++++++++++++++++++++++- Cargo.toml | 10 ++ examples/ui/src/main.rs | 70 +++++++--- src/app.rs | 225 +++++++++++++++++++++++--------- src/bin/pyfadetop.rs | 6 + src/cli.rs | 83 ++++++++++++ src/config.rs | 21 +-- src/lib.rs | 6 +- src/main.rs | 40 +----- src/priority.rs | 13 +- src/processes.rs | 139 ++++++++++++++++++++ src/sample.rs | 56 ++++++++ src/ser.rs | 17 --- src/state.rs | 40 +++--- src/subprocess_sampler.rs | 200 +++++++++++++++++++++++++++++ src/tabs/local_variables.rs | 2 +- src/tabs/mod.rs | 1 + src/tabs/process_selection.rs | 229 +++++++++++++++++++++++++++++++++ src/tabs/terminal_event.rs | 4 +- 20 files changed, 1217 insertions(+), 182 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 src/bin/pyfadetop.rs create mode 100644 src/cli.rs create mode 100644 src/processes.rs create mode 100644 src/sample.rs create mode 100644 src/subprocess_sampler.rs create mode 100644 src/tabs/process_selection.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..04b7d7a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-llzma"] diff --git a/Cargo.lock b/Cargo.lock index e460653..3d3ac09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,15 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.70.1" @@ -677,6 +686,15 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -696,6 +714,18 @@ dependencies = [ "const-random", ] +[[package]] +name = "educe" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4bd92664bf78c4d3dba9b7cdafce6fa15b13ed3ed16175218196942e99168a8" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "either" version = "1.15.0" @@ -717,6 +747,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -780,6 +830,7 @@ name = "fadetop" version = "0.1.2" dependencies = [ "anyhow", + "bincode", "clap 4.5.36", "config", "crossterm", @@ -790,6 +841,11 @@ dependencies = [ "remoteprocess", "serde", "tokio", + "tokio-serde", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", "use", ] @@ -1361,6 +1417,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e854583a83f20cf329bb9283366335387f7db59d640d1412167e05fedb98826" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1445,6 +1510,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-format" version = "0.4.4" @@ -1513,6 +1594,12 @@ version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "3.5.0" @@ -1599,6 +1686,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1623,6 +1730,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1839,8 +1952,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1851,9 +1973,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2298,6 +2426,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -2336,6 +2495,34 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "tokio-serde" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf600e7036b17782571dd44fa0a5cea3c82f60db5137f774a325a76a0d6852b" +dependencies = [ + "bincode", + "bytes", + "educe", + "futures-core", + "futures-sink", + "pin-project", + "serde", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.20" @@ -2377,9 +2564,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "tracing-core" version = "0.1.33" @@ -2400,15 +2611,33 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 098a511..578db9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,16 @@ ratatui = { workspace = true } tokio = { workspace = true } remoteprocess = "0.5.0" serde = "1.0.219" +bincode = "1.3" +tokio-util = { version = "0.7", features = ["codec"] } +tokio-serde = { version = "0.9", features = ["bincode"] } use = "0.0.1-pre.0" itertools = "0.14.0" futures = "0.3.31" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" + +[[bin]] +name = "pyfadetop" +path = "src/bin/pyfadetop.rs" diff --git a/examples/ui/src/main.rs b/examples/ui/src/main.rs index 380ac9c..27f5762 100644 --- a/examples/ui/src/main.rs +++ b/examples/ui/src/main.rs @@ -2,42 +2,76 @@ use std::sync::{Arc, RwLock}; use std::thread; use anyhow::Error; -use fadetop::app::{FadeTopApp, SamplerOps}; +use fadetop::app::SamplerOps; +use fadetop::config::AppConfig; use fadetop::priority::SpiedRecordQueueMap; -use py_spy::stack_trace::LocalVariable; -use py_spy::{Frame, Pid, StackTrace}; +use fadetop::processes::{ProcessDiscovery, PythonProcess}; +use fadetop::sample::{Frame, LocalVariable, Pid, StackTrace}; +use fadetop::tabs::process_selection::ProcessSelectionState; + +/// Mock process discovery that returns dummy processes for the demo +struct MockProcessDiscovery; + +impl ProcessDiscovery for MockProcessDiscovery { + fn discover() -> Vec { + vec![ + PythonProcess { + pid: 1001, + cmdline: "python3 /home/user/scripts/web_server.py --port 8080".to_string(), + }, + PythonProcess { + pid: 1002, + cmdline: "python3 -m pytest tests/".to_string(), + }, + PythonProcess { + pid: 1003, + cmdline: "python /usr/bin/jupyter notebook".to_string(), + }, + PythonProcess { + pid: 2001, + cmdline: "python3 data_pipeline.py --input data.csv".to_string(), + }, + PythonProcess { + pid: 3456, + cmdline: "python3 -c 'import time; time.sleep(3600)'".to_string(), + }, + ] + } +} #[derive(Clone, Debug, Default)] struct MockSampler {} impl SamplerOps for MockSampler { - fn push_to_queue(self, queue: Arc>) -> Result<(), Error> { + fn from_config_and_id(_config: &AppConfig, _pid: Pid) -> Result { + Ok(MockSampler {}) + } + + async fn push_to_queue(self, queue: Arc>) -> Result<(), Error> { + tokio::task::spawn_blocking(move || Self::push_to_queue_sync(queue)).await? + } +} + +impl MockSampler { + fn push_to_queue_sync(queue: Arc>) -> Result<(), Error> { loop { for pid in 0..10 { let frame_template = Frame { name: "level0".to_string(), filename: "lorem/ipsum/dolor/sit/amet/consectetur/adipiscing/elit/test.py" .to_string(), - line: 1, - module: Some("test".to_string()), - short_filename: Some("test.py".to_string()), locals: Some(vec![ LocalVariable { name: "x".to_string(), - addr: 10, - arg: true, repr: Some("data, verryyyyyy looonnnnnnng data".to_string()), }, LocalVariable { name: "είναι απλά ένα κείμενο".to_string(), - addr: 10, - arg: true, repr: Some( "χωρίς νόημα για τους επαγγελματίες της τυπογραφίας ".to_string(), ), }, ]), - is_entry: false, }; let trace = StackTrace { @@ -51,10 +85,6 @@ impl SamplerOps for MockSampler { frame_template.clone(), ], thread_name: Some("Main Thread".into()), - os_thread_id: None, - active: true, - owns_gil: false, - process_info: None, }; for _ in 0..10 { @@ -81,8 +111,6 @@ impl SamplerOps for MockSampler { name: "level3".to_string(), locals: Some(vec![LocalVariable { name: "x".to_string(), - addr: 10, - arg: true, repr: Some(format!("{:?}", total_events)), }]), ..frame_template.clone() @@ -136,10 +164,12 @@ impl SamplerOps for MockSampler { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Error> { + let configs = AppConfig::from_configs()?; let terminal = ratatui::init(); - let app = FadeTopApp::new(fadetop::config::AppConfig::from_configs()?); - let result = app.run(terminal, MockSampler {}).await; + let result = ProcessSelectionState::new() + .run_with_selection::(&configs, terminal) + .await; ratatui::restore(); result } diff --git a/src/app.rs b/src/app.rs index b8859fc..52cc060 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,19 @@ use crate::config::AppConfig; - -use crate::errors::AppError; use crate::priority::SpiedRecordQueueMap; -use crate::{state::AppState, tabs::terminal_event::UpdateEvent}; +use crate::processes::ProcessDiscovery; +use crate::state::ProfilingState; +use crate::tabs::process_selection::{ProcessSelectionAction, ProcessSelectionState}; +use crate::tabs::terminal_event::UpdateEvent; use anyhow::Error; use futures::StreamExt; -use py_spy::sampler; -use ratatui::{DefaultTerminal, crossterm}; +use ratatui::{DefaultTerminal, Frame, crossterm}; +use remoteprocess::Pid; use std::env; +use std::future::Future; +use std::sync::Arc; use std::sync::RwLock; use std::time::Duration; -use std::{sync::Arc, thread}; impl AppConfig { pub fn from_configs() -> Result { @@ -25,33 +27,13 @@ impl AppConfig { } } -pub trait SamplerOps: Send + 'static { - fn push_to_queue(self, record_queue_map: Arc>) - -> Result<(), Error>; -} - -impl SamplerOps for sampler::Sampler { +pub trait SamplerOps: Send + 'static + Sized { fn push_to_queue( self, record_queue_map: Arc>, - ) -> Result<(), Error> { - for sample in self { - for trace in sample.traces.iter() { - record_queue_map - .write() - .map_err(|_| AppError::SamplerSenderError)? - .increment(trace); - } - } + ) -> impl Future> + Send; - Ok(()) - } -} - -#[derive(Debug)] -pub struct FadeTopApp { - pub app_state: AppState, - update_period: Duration, + fn from_config_and_id(config: &AppConfig, pid: Pid) -> Result; } async fn send_terminal_event(tx: tokio::sync::mpsc::Sender) -> Result<(), Error> { @@ -69,45 +51,163 @@ async fn send_terminal_event(tx: tokio::sync::mpsc::Sender) -> Resu } } -impl FadeTopApp { - pub fn new(configs: AppConfig) -> Self { - let mut app_state = AppState::new(); - app_state - .record_queue_map - .write() - .unwrap() - .with_rules(configs.rules); +impl ProcessSelectionState { + /// Run the app starting from process selection (landing page mode) + pub async fn run_with_selection( + &mut self, + configs: &AppConfig, + mut terminal: DefaultTerminal, + ) -> Result<(), Error> { + self.refresh_processes::(); + + loop { + match self.run_selection_loop::(&mut terminal).await? { + SelectionResult::Quit => return Ok(()), + SelectionResult::AttachTo(pid) => { + match S::from_config_and_id(configs, pid) { + Ok(sampler) => { + ProfilingState::new(configs) + .run_attached(&mut terminal, sampler) + .await? + } + Err(e) => { + self.set_error(format!("Failed to attach to {}: {}", pid, e)); + } + } + self.refresh_processes::(); + } + } + } + } + /// Run the process selection UI loop + async fn run_selection_loop( + &mut self, + terminal: &mut DefaultTerminal, + ) -> Result { + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(2); + + // Terminal event sender + let term_handle = tokio::spawn({ + let tx = event_tx.clone(); + async move { + let _ = send_terminal_event(tx).await; + } + }); - app_state.viewport_bound.width = configs.window_width; + // Periodic refresh + let update_period = Duration::from_secs(2); + let periodic_handle = tokio::spawn({ + let tx = event_tx; + async move { + let mut interval = tokio::time::interval(update_period); + loop { + interval.tick().await; + if tx.send(UpdateEvent::Periodic).await.is_err() { + break; + } + } + } + }); - Self { - app_state, - update_period: configs.update_period, - } + let result = loop { + terminal.draw(|frame| self.render_selection(frame))?; + + match event_rx.recv().await { + None => break Ok(SelectionResult::Quit), + Some(UpdateEvent::Input(event)) => { + if let crossterm::event::Event::Key(key) = event { + match self.handle_focused_event(&key) { + ProcessSelectionAction::AttachTo(pid) => { + break Ok(SelectionResult::AttachTo(pid)); + } + ProcessSelectionAction::Quit => { + break Ok(SelectionResult::Quit); + } + ProcessSelectionAction::Refresh => { + self.refresh_processes::(); + } + ProcessSelectionAction::None => {} + } + } + } + Some(UpdateEvent::Periodic) => { + self.refresh_processes::(); + } + Some(UpdateEvent::Error(e)) => break Err(e.into()), + } + }; + + term_handle.abort(); + periodic_handle.abort(); + + result + } + fn render_selection(&mut self, frame: &mut Frame) { + use crate::tabs::StatefulWidgetExt; + use crate::tabs::process_selection::ProcessSelectionWidget; + frame.render_stateful_widget(ProcessSelectionWidget.blocked(), frame.area(), self); + } +} + +/// Result from process selection +enum SelectionResult { + Quit, + AttachTo(Pid), +} + +struct TaskHandles { + term_handle: tokio::task::JoinHandle<()>, + periodic_handle: tokio::task::JoinHandle<()>, + sampler_handle: tokio::task::JoinHandle<()>, +} + +impl TaskHandles { + fn abort(self) { + self.term_handle.abort(); + self.periodic_handle.abort(); + self.sampler_handle.abort(); + } +} + +impl ProfilingState { + async fn run_attached( + &mut self, + terminal: &mut DefaultTerminal, + sampler: S, + ) -> Result<(), Error> { + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(2); + let handles = self.run_event_senders(event_tx, sampler); + + let result = self.run_until_error(terminal, &mut event_rx).await; + + // Abort spawned tasks before returning to avoid lingering EventStream + handles.abort(); + + result } fn run_event_senders( &self, sender: tokio::sync::mpsc::Sender, sampler: S, - ) -> Result<(), Error> { - let term_sender = sender.clone(); - tokio::spawn({ + ) -> TaskHandles { + // Terminal event sender + let term_handle = tokio::spawn({ + let tx = sender.clone(); async move { - let _ = send_terminal_event(term_sender).await; + let _ = send_terminal_event(tx).await; } }); - let queue = Arc::clone(&self.app_state.record_queue_map); - thread::spawn({ - move || { - sampler.push_to_queue(queue).unwrap(); - } + // Sampler task + let queue = Arc::clone(&self.record_queue_map); + let sampler_handle = tokio::spawn(async move { + let _ = sampler.push_to_queue(queue).await; }); + // Periodic refresh let update_period = self.update_period; - - tokio::spawn(async move { + let periodic_handle = tokio::spawn(async move { let mut interval = tokio::time::interval(update_period); loop { interval.tick().await; @@ -117,21 +217,22 @@ impl FadeTopApp { } }); - Ok(()) + TaskHandles { + term_handle, + periodic_handle, + sampler_handle, + } } - pub async fn run( + /// Run with direct sampler attachment (CLI mode with PID argument) + pub async fn run_direct( mut self, - terminal: DefaultTerminal, + mut terminal: DefaultTerminal, sampler: S, ) -> Result<(), Error> { let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(2); + let _handles = self.run_event_senders(event_tx, sampler); - self.run_event_senders(event_tx, sampler)?; - - self.app_state - .run_until_error(terminal, &mut event_rx) - .await?; - Ok(()) + self.run_until_error(&mut terminal, &mut event_rx).await } } diff --git a/src/bin/pyfadetop.rs b/src/bin/pyfadetop.rs new file mode 100644 index 0000000..4c1e5f4 --- /dev/null +++ b/src/bin/pyfadetop.rs @@ -0,0 +1,6 @@ +use anyhow::Error; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Error> { + fadetop::cli::run().await +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..51ee4c7 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,83 @@ +use anyhow::Error; +use clap::{Parser, Subcommand}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +use remoteprocess::Pid; + +use crate::{ + app::SamplerOps, config::AppConfig, processes::SystemProcessDiscovery, state::ProfilingState, + subprocess_sampler, tabs::process_selection::ProcessSelectionState, +}; + +#[derive(Parser, Debug)] +#[command(version)] +struct Args { + pid: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Internal: run as subprocess worker (hidden from help) + #[command(hide = true)] + SubprocessWorker, + /// Show the current configuration and exit + ShowConfig, +} + +fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard { + let file_appender = tracing_appender::rolling::never("/tmp", "fadetop.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with(fmt::layer().with_writer(non_blocking).with_ansi(false)) + .init(); + + guard +} + +pub async fn run() -> Result<(), Error> { + let _guard = init_tracing(); + let args = Args::parse(); + + tracing::debug!(?args, "Parsed command line arguments"); + + // Handle subprocess worker mode (no config loading, no terminal) + if let Some(Command::SubprocessWorker) = args.command { + tracing::info!("Starting subprocess worker"); + return subprocess_sampler::run_worker(); + } + + let configs = AppConfig::from_configs()?; + + // Handle show-config command + if let Some(Command::ShowConfig) = args.command { + println!("{:#?}", configs); + return Ok(()); + } + + let terminal = ratatui::init(); + + let result = if let Some(pid) = args.pid { + // Direct attach mode (CLI with PID argument) + ProfilingState::new(&configs) + .run_direct( + terminal, + subprocess_sampler::SubprocessSampler::from_config_and_id(&configs, pid)?, + ) + .await + } else { + // Landing page mode (no PID argument) + ProcessSelectionState::new() + .run_with_selection::( + &configs, terminal, + ) + .await + }; + + ratatui::restore(); + result +} diff --git a/src/config.rs b/src/config.rs index 0b7a734..30c90b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,15 @@ use std::time::Duration; -use crate::{ - priority::ForgetRules, - ser::{parse_duration, parse_locking_strategy}, -}; -use py_spy::config::LockingStrategy; -use serde::Deserialize; +use crate::{priority::ForgetRules, ser::parse_duration}; +use serde::{Deserialize, Serialize}; + +/// Serializable version of py_spy's LockingStrategy +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum LockingStrategy { + Lock, + NonBlocking, + AlreadyLocked, +} fn default_sampling_rate() -> u64 { 10 @@ -55,9 +59,6 @@ pub struct AppConfig { pub rules: Vec, #[serde(default = "default_update_period")] pub update_period: Duration, - #[serde( - deserialize_with = "parse_locking_strategy", - default = "default_locking_strategy" - )] + #[serde(default = "default_locking_strategy")] pub locking_strategy: LockingStrategy, } diff --git a/src/lib.rs b/src/lib.rs index 115860a..adc6d9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,11 @@ pub mod app; +pub mod cli; pub mod config; pub mod errors; pub mod priority; +pub mod processes; +pub mod sample; pub mod ser; -mod state; +pub mod state; +pub mod subprocess_sampler; pub mod tabs; diff --git a/src/main.rs b/src/main.rs index 776a327..4c1e5f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,44 +1,6 @@ use anyhow::Error; -use clap::{CommandFactory, FromArgMatches, Parser, command}; -use fadetop::{app::FadeTopApp, config::AppConfig}; - -use py_spy; -use remoteprocess::Pid; - -#[derive(Parser, Debug)] -#[command(version)] -struct Args { - pid: Pid, -} #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Error> { - let configs = AppConfig::from_configs()?; - - let cmd = - Args::command().after_help(format!("Fadetop is being run with configs\n{:#?}", configs)); - - let args = Args::from_arg_matches_mut(&mut cmd.try_get_matches()?)?; - - let terminal = ratatui::init(); - let app = FadeTopApp::new(configs.clone()); - - let result = app - .run( - terminal, - py_spy::sampler::Sampler::new( - args.pid, - &py_spy::Config { - blocking: configs.locking_strategy, - sampling_rate: configs.sampling_rate, - subprocesses: configs.subprocesses, - native: configs.native, - dump_locals: configs.dump_locals, - ..Default::default() - }, - )?, - ) - .await; - ratatui::restore(); - result + fadetop::cli::run().await } diff --git a/src/priority.rs b/src/priority.rs index 82f3b95..d13ff83 100644 --- a/src/priority.rs +++ b/src/priority.rs @@ -1,6 +1,4 @@ -use py_spy::stack_trace::Frame; -use py_spy::stack_trace::LocalVariable; -use py_spy::stack_trace::StackTrace; +use crate::sample::{Frame, LocalVariable, StackTrace}; use remoteprocess::{Pid, Tid}; use serde::Deserialize; use std::cmp::Reverse; @@ -281,7 +279,6 @@ impl SpiedRecordQueueMap { #[cfg(test)] mod tests { use super::*; - use py_spy::stack_trace::StackTrace; #[test] fn test_compare_record() { @@ -316,11 +313,7 @@ mod tests { let frame_template = Frame { name: "level0".to_string(), filename: "test.py".to_string(), - line: 1, - module: Some("test".to_string()), - short_filename: Some("test.py".to_string()), locals: None, - is_entry: false, }; let trace = StackTrace { @@ -334,10 +327,6 @@ mod tests { frame_template.clone(), ], thread_name: None, - os_thread_id: None, - active: true, - owns_gil: false, - process_info: None, }; queues.increment(&trace); diff --git a/src/processes.rs b/src/processes.rs new file mode 100644 index 0000000..54a5412 --- /dev/null +++ b/src/processes.rs @@ -0,0 +1,139 @@ +use remoteprocess::Pid; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct PythonProcess { + pub pid: Pid, + pub cmdline: String, +} + +/// Trait for discovering Python processes +pub trait ProcessDiscovery { + fn discover() -> Vec; +} + +/// Real process discovery using the system's /proc filesystem +pub struct SystemProcessDiscovery; + +impl ProcessDiscovery for SystemProcessDiscovery { + fn discover() -> Vec { + discover_python_processes() + } +} + +impl PythonProcess { + /// Returns a short display name (script name or interpreter) + pub fn display_name(&self) -> &str { + // Try to extract the first meaningful argument (script name) + self.cmdline + .split_whitespace() + .nth(1) + .and_then(|arg| { + if arg.starts_with('-') { + None + } else { + Some(arg) + } + }) + .unwrap_or(&self.cmdline) + } +} + +/// Discovers Python processes on the system +fn discover_python_processes() -> Vec { + #[cfg(target_os = "linux")] + { + discover_linux() + } + #[cfg(not(target_os = "linux"))] + { + Vec::new() + } +} + +#[cfg(target_os = "linux")] +fn discover_linux() -> Vec { + let mut processes = Vec::new(); + let current_pid = std::process::id() as Pid; + + let Ok(proc_entries) = fs::read_dir("/proc") else { + return processes; + }; + + for entry in proc_entries.flatten() { + let path = entry.path(); + + // Check if this is a PID directory + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Ok(pid) = name.parse::() else { + continue; + }; + + // Skip self + if pid == current_pid { + continue; + } + + // Check if it's a Python process + if !is_python_process(&path) { + continue; + } + + // Read command line + let cmdline = read_cmdline(&path); + if cmdline.is_empty() { + continue; + } + + processes.push(PythonProcess { pid, cmdline }); + } + + // Sort by PID for stable ordering + processes.sort_by_key(|p| p.pid); + processes +} + +#[cfg(target_os = "linux")] +fn is_python_process(proc_path: &Path) -> bool { + let exe_path = proc_path.join("exe"); + + // Read the symlink target + if let Ok(target) = fs::read_link(&exe_path) { + let target_str = target.to_string_lossy(); + // Check if executable is python + if target_str.contains("python") { + return true; + } + } + + // Fallback: check /proc/[pid]/comm + let comm_path = proc_path.join("comm"); + if let Ok(comm) = fs::read_to_string(&comm_path) { + let comm = comm.trim(); + if comm.starts_with("python") { + return true; + } + } + + false +} + +#[cfg(target_os = "linux")] +fn read_cmdline(proc_path: &Path) -> String { + let cmdline_path = proc_path.join("cmdline"); + + fs::read(&cmdline_path) + .map(|bytes| { + // cmdline is null-separated + bytes + .split(|&b| b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect::>() + .join(" ") + }) + .unwrap_or_default() +} diff --git a/src/sample.rs b/src/sample.rs new file mode 100644 index 0000000..54dd034 --- /dev/null +++ b/src/sample.rs @@ -0,0 +1,56 @@ +pub use remoteprocess::Pid; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalVariable { + pub name: String, + pub repr: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Frame { + pub name: String, + pub filename: String, + pub locals: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackTrace { + pub thread_id: u64, + pub pid: Pid, + pub frames: Vec, + pub thread_name: Option, +} + +impl From<&py_spy::stack_trace::LocalVariable> for LocalVariable { + fn from(local: &py_spy::stack_trace::LocalVariable) -> Self { + LocalVariable { + name: local.name.clone(), + repr: local.repr.clone(), + } + } +} + +impl From<&py_spy::stack_trace::Frame> for Frame { + fn from(frame: &py_spy::stack_trace::Frame) -> Self { + Frame { + name: frame.name.clone(), + filename: frame.filename.clone(), + locals: frame + .locals + .as_ref() + .map(|locals| locals.iter().map(LocalVariable::from).collect()), + } + } +} + +impl From<&py_spy::stack_trace::StackTrace> for StackTrace { + fn from(trace: &py_spy::stack_trace::StackTrace) -> Self { + StackTrace { + thread_id: trace.thread_id, + pid: trace.pid, + frames: trace.frames.iter().map(Frame::from).collect(), + thread_name: trace.thread_name.clone(), + } + } +} diff --git a/src/ser.rs b/src/ser.rs index 39530f5..ac1dddf 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -1,6 +1,5 @@ use std::time::Duration; -use py_spy::config::LockingStrategy; use serde::{Deserialize, Deserializer}; pub fn parse_duration<'de, D>(deserializer: D) -> Result @@ -24,19 +23,3 @@ where ))), } } - -pub fn parse_locking_strategy<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer)?; - - match s.as_ref() { - "Lock" => Ok(LockingStrategy::Lock), - "NonBlocking" => Ok(LockingStrategy::NonBlocking), - _ => Err(serde::de::Error::custom(format!( - "invalid locking strategy '{}'", - s - ))), - } -} diff --git a/src/state.rs b/src/state.rs index 61984a2..69f93d7 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,7 @@ -use std::sync::{Arc, RwLock}; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; use anyhow::Error; use ratatui::{ @@ -12,6 +15,7 @@ use ratatui::{ use tokio::sync::mpsc::Receiver; use crate::{ + config::AppConfig, priority::SpiedRecordQueueMap, tabs::{ StatefulWidgetExt, @@ -22,7 +26,7 @@ use crate::{ }, }; -// Add a Focus enum to track current focus +/// Focus within the profiling view #[derive(Debug, PartialEq, Eq)] pub enum Focus { ThreadList, @@ -30,8 +34,9 @@ pub enum Focus { LogView, } +/// State for the profiling view (when attached to a process) #[derive(Debug)] -pub struct AppState { +pub struct ProfilingState { focus: Focus, thread_selection: ThreadSelectionState, pub(super) viewport_bound: ViewPortBounds, @@ -39,43 +44,49 @@ pub struct AppState { pub record_queue_map: Arc>, running: bool, ratio: u16, + pub update_period: Duration, } -impl AppState { +impl ProfilingState { fn quit(&mut self) { self.running = false; } pub async fn run_until_error( &mut self, - mut terminal: DefaultTerminal, + terminal: &mut DefaultTerminal, rx: &mut Receiver, ) -> Result<(), Error> { while self.running { - terminal.draw(|frame| self.render_full_app(frame))?; + terminal.draw(|frame| self.render(frame))?; match rx.recv().await { - None => { - break; - } + None => break, Some(event) => event.update_state(self)?, }; } Ok(()) } - pub fn new() -> Self { + pub fn new(config: &AppConfig) -> Self { + let mut record_queue_map: SpiedRecordQueueMap = Default::default(); + record_queue_map.with_rules(config.rules.clone()); + + let mut viewport_bound = ViewPortBounds::default(); + viewport_bound.width = config.window_width; + Self { focus: Focus::ThreadList, thread_selection: Default::default(), - record_queue_map: Default::default(), - viewport_bound: Default::default(), + viewport_bound, local_variable_state: LocalVariableSelection::default(), + record_queue_map: Arc::new(RwLock::new(record_queue_map)), running: true, ratio: 80, + update_period: config.update_period, } } - fn render_full_app(&mut self, frame: &mut Frame) { + pub fn render(&mut self, frame: &mut Frame) { let out_block = { Block::default() .borders(Borders::NONE) @@ -148,8 +159,7 @@ impl AppState { pub fn handle_crossterm_events(&mut self, term_event: event::Event) -> Result<(), Error> { match term_event { event::Event::Key(key) => match (key.modifiers, key.code) { - // Global shortcuts - (_, event::KeyCode::Esc) => Ok(self.quit()), + (_, event::KeyCode::Esc | event::KeyCode::Char('q')) => Ok(self.quit()), (_, event::KeyCode::Tab) => { self.focus = match self.focus { Focus::ThreadList => Focus::Timeline, diff --git a/src/subprocess_sampler.rs b/src/subprocess_sampler.rs new file mode 100644 index 0000000..64db55a --- /dev/null +++ b/src/subprocess_sampler.rs @@ -0,0 +1,200 @@ +use crate::app::SamplerOps; +use crate::config::{AppConfig, LockingStrategy}; +use crate::errors::AppError; +use crate::priority::SpiedRecordQueueMap; +use crate::sample::StackTrace; +use anyhow::Error; +use futures::TryStreamExt; +use remoteprocess::Pid; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, RwLock}; +use tokio_util::codec::{Encoder, FramedRead, LengthDelimitedCodec}; +use tracing::{debug, info, instrument, trace}; + +/// Configuration passed to the subprocess worker via stdin +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkerConfig { + pub pid: Pid, + pub sampling_rate: u64, + pub subprocesses: bool, + pub native: bool, + pub dump_locals: u64, + pub locking_strategy: LockingStrategy, +} + +impl WorkerConfig { + pub fn new(config: &AppConfig, pid: Pid) -> Self { + WorkerConfig { + pid, + sampling_rate: config.sampling_rate, + subprocesses: config.subprocesses, + native: config.native, + dump_locals: config.dump_locals, + locking_strategy: config.locking_strategy, + } + } +} + +/// A sampler that runs py-spy in a subprocess and communicates via serialized traces. +pub struct SubprocessSampler { + child: Child, +} + +impl SubprocessSampler { + /// Spawn a subprocess sampler for the given PID. + #[instrument(skip(config), fields(pid = pid))] + pub fn new(config: &AppConfig, pid: Pid) -> Result { + let exe = std::env::current_exe()?; + debug!(?exe, "Spawning subprocess worker"); + + let mut child = Command::new(exe) + .arg("subprocess-worker") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + // Send config via stdin as length-delimited bincode + let worker_config = WorkerConfig::new(config, pid); + let data = bincode::serialize(&worker_config)?; + debug!(config_size = data.len(), "Sending config to subprocess"); + + let mut stdin = child.stdin.take().ok_or(AppError::SamplerSenderError)?; + let mut codec = LengthDelimitedCodec::new(); + let mut buf = tokio_util::bytes::BytesMut::new(); + codec.encode(data.into(), &mut buf)?; + stdin.write_all(&buf)?; + stdin.flush()?; + drop(stdin); // Close stdin to signal we're done + + info!("Subprocess spawned successfully"); + Ok(SubprocessSampler { child }) + } +} + +impl Drop for SubprocessSampler { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl SamplerOps for SubprocessSampler { + fn from_config_and_id(config: &AppConfig, pid: Pid) -> Result { + SubprocessSampler::new(config, pid) + } + + #[instrument(skip_all)] + async fn push_to_queue( + mut self, + record_queue_map: Arc>, + ) -> Result<(), Error> { + let stdout = self + .child + .stdout + .take() + .ok_or(AppError::SamplerSenderError)?; + + // Convert std stdout to tokio async reader + let stdout = tokio::io::BufReader::new(tokio::process::ChildStdout::from_std(stdout)?); + + let mut length_delimited = FramedRead::new(stdout, LengthDelimitedCodec::new()); + + debug!("Waiting for traces from subprocess"); + let mut trace_count = 0u64; + while let Some(frame) = length_delimited.try_next().await? { + trace!(frame_size = frame.len(), "Received frame"); + let trace: StackTrace = bincode::deserialize(&frame)?; + record_queue_map + .write() + .map_err(|_| AppError::SamplerSenderError)? + .increment(&trace); + trace_count += 1; + } + + info!(trace_count, "Subprocess stream ended"); + Ok(()) + } +} + +impl From for py_spy::config::LockingStrategy { + fn from(strategy: LockingStrategy) -> Self { + match strategy { + LockingStrategy::Lock => py_spy::config::LockingStrategy::Lock, + LockingStrategy::NonBlocking => py_spy::config::LockingStrategy::NonBlocking, + LockingStrategy::AlreadyLocked => py_spy::config::LockingStrategy::AlreadyLocked, + } + } +} + +/// Entry point for the subprocess worker +#[instrument] +pub fn run_worker() -> Result<(), Error> { + use std::io::Read; + use tokio_util::bytes::BytesMut; + use tokio_util::codec::Decoder; + + debug!("Reading config from stdin"); + + // Read config from stdin (length-delimited bincode) + let stdin = std::io::stdin(); + let mut stdin = stdin.lock(); + let mut input_buf = Vec::new(); + stdin.read_to_end(&mut input_buf)?; + + debug!(bytes = input_buf.len(), "Received config data"); + + let mut codec = LengthDelimitedCodec::new(); + let mut bytes = BytesMut::from(input_buf.as_slice()); + let frame = codec + .decode(&mut bytes)? + .ok_or_else(|| anyhow::anyhow!("No config received on stdin"))?; + let config: WorkerConfig = bincode::deserialize(&frame)?; + + info!( + pid = config.pid, + sampling_rate = config.sampling_rate, + "Config decoded, creating sampler" + ); + + let sampler = py_spy::sampler::Sampler::new( + config.pid, + &py_spy::Config { + blocking: config.locking_strategy.into(), + sampling_rate: config.sampling_rate, + subprocesses: config.subprocesses, + native: config.native, + dump_locals: config.dump_locals, + ..Default::default() + }, + )?; + + info!("Sampler created, starting trace collection"); + + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + let mut codec = LengthDelimitedCodec::new(); + let mut buf = BytesMut::new(); + + let mut sample_count = 0u64; + let mut trace_count = 0u64; + for sample in sampler { + sample_count += 1; + trace!(sample_count, traces = sample.traces.len(), "Got sample"); + for trace in sample.traces.iter() { + let owned_trace: StackTrace = trace.into(); + let data = bincode::serialize(&owned_trace)?; + + codec.encode(data.into(), &mut buf)?; + stdout.write_all(&buf)?; + stdout.flush()?; + buf.clear(); + trace_count += 1; + } + } + + info!(sample_count, trace_count, "Sampling completed"); + Ok(()) +} diff --git a/src/tabs/local_variables.rs b/src/tabs/local_variables.rs index cbf5264..e385987 100644 --- a/src/tabs/local_variables.rs +++ b/src/tabs/local_variables.rs @@ -1,4 +1,4 @@ -use py_spy::stack_trace::LocalVariable; +use crate::sample::LocalVariable; use ratatui::{ buffer::Buffer, crossterm::event::{self, KeyEvent}, diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs index 43c3469..954e475 100644 --- a/src/tabs/mod.rs +++ b/src/tabs/mod.rs @@ -5,6 +5,7 @@ use ratatui::{ }; pub mod local_variables; +pub mod process_selection; pub mod terminal_event; pub mod thread_selection; pub mod timeline; diff --git a/src/tabs/process_selection.rs b/src/tabs/process_selection.rs new file mode 100644 index 0000000..17d1817 --- /dev/null +++ b/src/tabs/process_selection.rs @@ -0,0 +1,229 @@ +use ratatui::{ + buffer::Buffer, + crossterm::event::{self, KeyEvent}, + layout::Rect, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Paragraph, StatefulWidget, Widget}, +}; +use remoteprocess::Pid; + +use crate::processes::{ProcessDiscovery, PythonProcess}; + +use super::{StatefulWidgetExt, get_scroll}; + +/// Actions that can result from process selection events +#[derive(Debug, Clone)] +pub enum ProcessSelectionAction { + None, + AttachTo(Pid), + Refresh, + Quit, +} + +#[derive(Debug, Clone, Default)] +pub struct ProcessSelectionState { + processes: Vec, + selected_index: usize, + filter_text: String, + filter_mode: bool, + /// Error message to display (e.g., failed to attach) + pub error_message: Option, +} + +impl ProcessSelectionState { + pub fn new() -> Self { + Self::default() + } + + /// Refresh the process list using the given discovery implementation + pub fn refresh_processes(&mut self) { + self.processes = D::discover(); + // Clamp selected index if list shrunk + let filtered_len = self.filtered_processes().len(); + if self.selected_index >= filtered_len && filtered_len > 0 { + self.selected_index = filtered_len - 1; + } + } + + /// Returns filtered processes based on current filter + pub fn filtered_processes(&self) -> Vec<&PythonProcess> { + if self.filter_text.is_empty() { + self.processes.iter().collect() + } else { + let filter_lower = self.filter_text.to_lowercase(); + self.processes + .iter() + .filter(|p| p.cmdline.to_lowercase().contains(&filter_lower)) + .collect() + } + } + + /// Get the currently selected process PID + pub fn selected_pid(&self) -> Option { + self.filtered_processes() + .get(self.selected_index) + .map(|p| p.pid) + } + + /// Handle keyboard events when this widget has focus + pub fn handle_focused_event(&mut self, key: &KeyEvent) -> ProcessSelectionAction { + if self.filter_mode { + self.handle_filter_mode_event(key) + } else { + self.handle_normal_mode_event(key) + } + } + + fn handle_filter_mode_event(&mut self, key: &KeyEvent) -> ProcessSelectionAction { + match key.code { + event::KeyCode::Esc => { + self.filter_mode = false; + ProcessSelectionAction::None + } + event::KeyCode::Enter => { + self.filter_mode = false; + ProcessSelectionAction::None + } + event::KeyCode::Backspace => { + self.filter_text.pop(); + self.selected_index = 0; + ProcessSelectionAction::None + } + event::KeyCode::Char(c) => { + self.filter_text.push(c); + self.selected_index = 0; + ProcessSelectionAction::None + } + _ => ProcessSelectionAction::None, + } + } + + fn handle_normal_mode_event(&mut self, key: &KeyEvent) -> ProcessSelectionAction { + // Clear error message on any key press + self.error_message = None; + + match key.code { + event::KeyCode::Down | event::KeyCode::Char('j') => { + let max = self.filtered_processes().len().saturating_sub(1); + self.selected_index = (self.selected_index + 1).min(max); + ProcessSelectionAction::None + } + event::KeyCode::Up | event::KeyCode::Char('k') => { + self.selected_index = self.selected_index.saturating_sub(1); + ProcessSelectionAction::None + } + event::KeyCode::Enter => { + if let Some(pid) = self.selected_pid() { + ProcessSelectionAction::AttachTo(pid) + } else { + ProcessSelectionAction::None + } + } + event::KeyCode::Char('/') => { + self.filter_mode = true; + ProcessSelectionAction::None + } + event::KeyCode::Char('r') => ProcessSelectionAction::Refresh, + event::KeyCode::Esc => ProcessSelectionAction::Quit, + _ => ProcessSelectionAction::None, + } + } + + /// Set an error message to display + pub fn set_error(&mut self, message: String) { + self.error_message = Some(message); + } +} + +pub struct ProcessSelectionWidget; + +impl StatefulWidget for ProcessSelectionWidget { + type State = ProcessSelectionState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + if area.is_empty() { + return; + } + + let filtered = state.filtered_processes(); + + if filtered.is_empty() { + let msg = if state.processes.is_empty() { + "No Python processes found. Press 'r' to refresh." + } else { + "No matches. Press Backspace or Esc to clear filter." + }; + Paragraph::new(msg) + .style(Style::default().fg(Color::DarkGray)) + .render(area, buf); + return; + } + + let lines: Vec = filtered + .iter() + .enumerate() + .map(|(i, proc)| { + let pid_span = Span::styled( + format!("{:>8} ", proc.pid), + Style::default().fg(Color::Cyan), + ); + let cmd_span = Span::raw(&proc.cmdline); + + let mut line = Line::from(vec![pid_span, cmd_span]); + + if i == state.selected_index { + line = line.bg(Color::Blue).bold(); + } + + line + }) + .collect(); + + let scroll_offset = get_scroll(state.selected_index as u16, area.height); + + Paragraph::new(lines) + .scroll((scroll_offset, 0)) + .render(area, buf); + } +} + +impl StatefulWidgetExt for ProcessSelectionWidget { + fn get_block(&self, state: &mut Self::State) -> Block<'_> { + let title = format!("Select Python Process ({} found)", state.processes.len()); + + let mut block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::new().blue()); + + // Show error message if present + if let Some(ref error) = state.error_message { + block = block.title_bottom( + Line::from(error.clone()) + .centered() + .style(Style::default().fg(Color::Red).bold()), + ); + return block; + } + + // Show filter or help text at bottom + if state.filter_mode { + let filter_line = format!("Filter: {}_", state.filter_text); + block = block.title_bottom( + Line::from(filter_line) + .left_aligned() + .style(Style::default().fg(Color::Yellow)), + ); + } else if !state.filter_text.is_empty() { + let filter_display = format!("Filter: {} (/ to edit, Esc to clear)", state.filter_text); + block = block.title_bottom(Line::from(filter_display).left_aligned()); + } else { + block = block + .title_bottom(Line::from("Enter:attach /:filter r:refresh Esc:quit").centered()); + } + + block + } +} diff --git a/src/tabs/terminal_event.rs b/src/tabs/terminal_event.rs index cd04fd6..37f9c79 100644 --- a/src/tabs/terminal_event.rs +++ b/src/tabs/terminal_event.rs @@ -1,7 +1,7 @@ use anyhow::Error; use ratatui::crossterm; -use crate::{errors::AppError, state::AppState}; +use crate::{errors::AppError, state::ProfilingState}; pub enum UpdateEvent { Periodic, @@ -10,7 +10,7 @@ pub enum UpdateEvent { } impl UpdateEvent { - pub fn update_state(self, app_state: &mut AppState) -> Result<(), Error> { + pub fn update_state(self, app_state: &mut ProfilingState) -> Result<(), Error> { match self { UpdateEvent::Input(term_event) => app_state.handle_crossterm_events(term_event), UpdateEvent::Periodic => Ok(()),