diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index 5559834..0af7c21 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -35,6 +35,10 @@ pub enum ActionOutcome { NoOp, } +/// Maximum number of undo snapshots to retain. Keeps memory +/// bounded when users toggle hundreds of selections. +const UNDO_STACK_LIMIT: usize = 50; + /// Tracks multi-select state: which items are selected (by /// original index), with undo/redo support. pub struct SelectionState { @@ -57,6 +61,9 @@ impl SelectionState { } fn push_undo(&mut self) { + if self.undo_stack.len() >= UNDO_STACK_LIMIT { + self.undo_stack.remove(0); + } self.undo_stack.push(self.selected.clone()); self.redo_stack.clear(); } diff --git a/crates/pikl-test-macros/src/codegen.rs b/crates/pikl-test-macros/src/codegen.rs index aabdd7a..b94f0e6 100644 --- a/crates/pikl-test-macros/src/codegen.rs +++ b/crates/pikl-test-macros/src/codegen.rs @@ -141,6 +141,16 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result syn::Result { } } } else { - // No assertion on result. Probably an error, but let it compile. - quote! {} + return Err(syn::Error::new( + case.name.span(), + format!( + "menu test '{}' has no result assertion (expected selected, selected_items, or cancelled)", + case.name + ), + )); }; // If test expects cancellation via sender drop (no cancel action, no confirm), diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index f826624..42eb535 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -71,12 +71,21 @@ async fn run_inner( // Send initial resize let size = terminal.size()?; let list_height = size.height.saturating_sub(1); - let _ = action_tx + if action_tx .send(Action::Resize { height: list_height, }) - .await; + .await + .is_err() + { + tracing::warn!("core channel closed before TUI started"); + return Ok(()); + } + // Local copy of filter text for immediate cursor positioning. + // Synced from core on every StateChanged. Core is the source + // of truth: if IPC or a hook changes the filter, the next + // broadcast overwrites this. let mut filter_text = String::new(); let mut view_state: Option = None; let mut event_stream = EventStream::new(); @@ -124,12 +133,16 @@ async fn run_inner( mode = *m; pending = PendingKey::None; } - let _ = action_tx.send(action).await; + if action_tx.send(action).await.is_err() { + break; // core is gone + } } } Event::Resize(_, h) => { let list_height = h.saturating_sub(1); - let _ = action_tx.send(Action::Resize { height: list_height }).await; + if action_tx.send(Action::Resize { height: list_height }).await.is_err() { + break; + } } _ => {} } diff --git a/crates/pikl/src/handler.rs b/crates/pikl/src/handler.rs index 163e53d..0e5a929 100644 --- a/crates/pikl/src/handler.rs +++ b/crates/pikl/src/handler.rs @@ -76,7 +76,9 @@ impl HookHandler for ShellHandlerHook { 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); + if let Err(e) = tx.try_send(event) { + tracing::debug!(error = %e, "handler event channel full, dropping event"); + } } Ok(()) } @@ -128,7 +130,13 @@ async fn run_handler_process( // Writer loop: reads events from channel, writes to stdin while let Some(event) = event_rx.recv().await { - let json = serde_json::to_string(&event).unwrap_or_default(); + let json = match serde_json::to_string(&event) { + Ok(j) => j, + Err(e) => { + tracing::warn!(error = %e, "failed to serialize hook event, skipping"); + continue; + } + }; if stdin_writer.write_all(json.as_bytes()).await.is_err() { break; } diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 14fe216..9f77731 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -353,8 +353,9 @@ struct CompositeHookHandler { impl HookHandler for CompositeHookHandler { fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> { - // Both fire. Exec is fire-and-forget, handler may - // send responses through action_tx. + // Exec hooks are fire-and-forget: errors are logged + // internally by ShellExecHandler, not propagated here. + // Handler hooks are bidirectional, so errors propagate. let _ = self.exec.handle(event.clone()); if let Some(ref h) = self.handler { h.handle(event)?; @@ -507,7 +508,7 @@ fn handle_result(result: Result, cli: &Cli) { /// Serialize an OutputItem as JSON and write it to the given writer. fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> { - let json = serde_json::to_string(output).unwrap_or_default(); + let json = serde_json::to_string(output).map_err(|e| std::io::Error::other(e.to_string()))?; writeln!(writer, "{json}") } @@ -517,7 +518,8 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std:: match value { Value::String(s) => writeln!(writer, "{s}"), _ => { - let json = serde_json::to_string(value).unwrap_or_default(); + let json = + serde_json::to_string(value).map_err(|e| std::io::Error::other(e.to_string()))?; writeln!(writer, "{json}") } }