diff --git a/README.md b/README.md index 180c0bc..b1c9f5b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# pikl-menu +# pikl Pipe stuff in, pick stuff out. diff --git a/crates/pikl-core/src/runtime/debounce.rs b/crates/pikl-core/src/runtime/debounce.rs index 3dd8620..4828e90 100644 --- a/crates/pikl-core/src/runtime/debounce.rs +++ b/crates/pikl-core/src/runtime/debounce.rs @@ -29,7 +29,7 @@ pub enum DebounceMode { /// behavior. Each event kind can have its own mode. pub struct DebouncedDispatcher { handler: Arc, - action_tx: mpsc::Sender, + _action_tx: mpsc::Sender, modes: HashMap, in_flight: HashMap>, } @@ -41,7 +41,7 @@ impl DebouncedDispatcher { ) -> Self { Self { handler, - action_tx, + _action_tx: action_tx, modes: HashMap::new(), in_flight: HashMap::new(), } @@ -95,17 +95,9 @@ impl DebouncedDispatcher { fn fire_now(&self, event: HookEvent) { let handler = Arc::clone(&self.handler); - let action_tx = self.action_tx.clone(); tokio::spawn(async move { - match handler.handle(event) { - Ok(responses) => { - for resp in responses { - let _ = action_tx.send(Action::ProcessHookResponse(resp)).await; - } - } - Err(e) => { - tracing::warn!(error = %e, "hook handler error"); - } + if let Err(e) = handler.handle(event) { + tracing::warn!(error = %e, "hook handler error"); } }); } @@ -117,18 +109,10 @@ impl DebouncedDispatcher { } let handler = Arc::clone(&self.handler); - let action_tx = self.action_tx.clone(); let handle = tokio::spawn(async move { tokio::time::sleep(delay).await; - match handler.handle(event) { - Ok(responses) => { - for resp in responses { - let _ = action_tx.send(Action::ProcessHookResponse(resp)).await; - } - } - Err(e) => { - tracing::warn!(error = %e, "hook handler error"); - } + if let Err(e) = handler.handle(event) { + tracing::warn!(error = %e, "hook handler error"); } }); @@ -165,11 +149,11 @@ mod tests { } impl HookHandler for RecordingHandler { - fn handle(&self, event: HookEvent) -> Result, PiklError> { + fn handle(&self, event: HookEvent) -> Result<(), PiklError> { if let Ok(mut events) = self.events.lock() { events.push(event.kind()); } - Ok(vec![]) + Ok(()) } } diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs index 7ad4007..03a75f3 100644 --- a/crates/pikl-core/src/runtime/hook.rs +++ b/crates/pikl-core/src/runtime/hook.rs @@ -65,16 +65,15 @@ pub enum HookResponse { } /// Handler trait for lifecycle hooks. Implementations -/// receive events and optionally return responses. -/// Exec hooks return empty vecs. Handler hooks send -/// responses back through the action channel asynchronously -/// and also return empty vecs. +/// receive events and may produce side effects (spawning +/// processes, sending to channels). Responses flow through +/// the action channel, not the return value. /// /// This is deliberately synchronous for dyn-compatibility. /// Implementations that need async work (spawning processes, /// writing to channels) should use `tokio::spawn` internally. pub trait HookHandler: Send + Sync { - fn handle(&self, event: HookEvent) -> Result, PiklError>; + fn handle(&self, event: HookEvent) -> Result<(), PiklError>; } /// Parse a single line of JSON as a [`HookResponse`]. diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index e27058b..e5ee1f8 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -11,7 +11,7 @@ use crate::debounce::{hook_response_to_action, DebouncedDispatcher}; use crate::error::PiklError; use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; use crate::hook::{HookEvent, HookHandler}; -use crate::model::traits::Menu; +use crate::model::traits::MutableMenu; use crate::navigation::Viewport; use serde_json::Value; @@ -37,7 +37,7 @@ pub enum ActionOutcome { /// drives it with an action/event channel loop. Create one, /// grab the action sender and event subscriber, then call /// [`MenuRunner::run`] to start the event loop. -pub struct MenuRunner { +pub struct MenuRunner { menu: M, viewport: Viewport, filter_text: Arc, @@ -46,9 +46,10 @@ pub struct MenuRunner { event_tx: broadcast::Sender, dispatcher: Option, previous_cursor: Option, + generation: u64, } -impl MenuRunner { +impl MenuRunner { /// Create a menu runner wrapping the given menu backend. /// Returns the runner and an action sender. Call /// [`subscribe`](Self::subscribe) to get an event handle, @@ -69,6 +70,7 @@ impl MenuRunner { event_tx, dispatcher: None, previous_cursor: None, + generation: 0, }; (runner, action_tx) } @@ -105,7 +107,8 @@ impl MenuRunner { /// Build a [`ViewState`] snapshot from the current filter /// results and viewport position. - fn build_view_state(&self) -> ViewState { + fn build_view_state(&mut self) -> ViewState { + self.generation += 1; let range = self.viewport.visible_range(); let visible_items: Vec = range .clone() @@ -134,14 +137,14 @@ impl MenuRunner { total_items: self.menu.total(), total_filtered: self.menu.filtered_count(), mode: self.mode, + generation: self.generation, } } /// Send the current view state to all subscribers. - fn broadcast_state(&self) { - let _ = self - .event_tx - .send(MenuEvent::StateChanged(self.build_view_state())); + fn broadcast_state(&mut self) { + let vs = self.build_view_state(); + let _ = self.event_tx.send(MenuEvent::StateChanged(vs)); } /// Emit a hook event through the dispatcher, if one is set. @@ -390,6 +393,7 @@ mod tests { use super::*; use crate::event::MenuEvent; use crate::item::Item; + use crate::model::traits::Menu; use crate::runtime::json_menu::JsonMenu; fn test_menu() -> (MenuRunner, mpsc::Sender) { @@ -1099,16 +1103,16 @@ mod tests { #[tokio::test] async fn hook_events_fire_on_lifecycle() { - use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; + use crate::hook::{HookEvent, HookEventKind, HookHandler}; use std::sync::Mutex; struct Recorder(Mutex>); impl HookHandler for Recorder { - fn handle(&self, event: HookEvent) -> Result, PiklError> { + fn handle(&self, event: HookEvent) -> Result<(), PiklError> { if let Ok(mut v) = self.0.lock() { v.push(event.kind()); } - Ok(vec![]) + Ok(()) } } diff --git a/crates/pikl/src/handler.rs b/crates/pikl/src/handler.rs index 806a916..4c912e3 100644 --- a/crates/pikl/src/handler.rs +++ b/crates/pikl/src/handler.rs @@ -13,7 +13,7 @@ use tokio::sync::mpsc; use pikl_core::error::PiklError; use pikl_core::event::Action; use pikl_core::hook::{ - parse_hook_response, HookEvent, HookEventKind, HookHandler, HookResponse, + parse_hook_response, HookEvent, HookEventKind, HookHandler, }; /// A persistent handler hook process. Spawns a child process, @@ -77,13 +77,13 @@ impl ShellHandlerHook { } impl HookHandler for ShellHandlerHook { - fn handle(&self, event: HookEvent) -> Result, PiklError> { + fn handle(&self, event: HookEvent) -> Result<(), PiklError> { let kind = event.kind(); if let Some(tx) = self.event_txs.get(&kind) { // Non-blocking send. If the channel is full, drop the event. let _ = tx.try_send(event); } - Ok(vec![]) + Ok(()) } } diff --git a/crates/pikl/src/hook.rs b/crates/pikl/src/hook.rs index fce3748..ea26390 100644 --- a/crates/pikl/src/hook.rs +++ b/crates/pikl/src/hook.rs @@ -9,7 +9,7 @@ use tokio::io::AsyncWriteExt; use tokio::process::Command; use pikl_core::error::PiklError; -use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; +use pikl_core::hook::{HookEvent, HookEventKind, HookHandler}; /// Duplicate stderr as a [`Stdio`] handle for use as a /// child process's stdout. Keeps hook output on stderr @@ -18,8 +18,12 @@ fn stderr_as_stdio() -> std::process::Stdio { #[cfg(unix)] { use std::os::unix::io::FromRawFd; + // SAFETY: STDERR_FILENO is a valid open fd in any running process. + // dup() returns a new fd or -1, which we check below. let fd = unsafe { libc::dup(libc::STDERR_FILENO) }; if fd >= 0 { + // SAFETY: fd is valid (checked >= 0 above) and we take exclusive + // ownership here (no aliasing). The File will close it on drop. return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) }; } } @@ -112,7 +116,7 @@ impl ShellExecHandler { } impl HookHandler for ShellExecHandler { - fn handle(&self, event: HookEvent) -> Result, PiklError> { + fn handle(&self, event: HookEvent) -> Result<(), PiklError> { let kind = event.kind(); if let Some(cmd) = self.commands.get(&kind) { let cmd = cmd.clone(); @@ -125,7 +129,7 @@ impl HookHandler for ShellExecHandler { } }); } - Ok(vec![]) + Ok(()) } } diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 2f324a0..5f5ac1c 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -310,14 +310,14 @@ impl HookHandler for CompositeHookHandler { fn handle( &self, event: pikl_core::hook::HookEvent, - ) -> Result, PiklError> { + ) -> Result<(), PiklError> { // Both fire. Exec is fire-and-forget, handler may // send responses through action_tx. let _ = self.exec.handle(event.clone()); if let Some(ref h) = self.handler { h.handle(event)?; } - Ok(vec![]) + Ok(()) } }