Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion devenv-activity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub use handle::{ActivityHandle, init};
pub use serde_valuable::SerdeValue;
pub use stack::{
current_activity_id, current_activity_level, log_to_evaluate, message, message_with_details,
op_to_evaluate, set_expected,
message_with_parent, op_to_evaluate, set_expected,
};

// Trait
Expand Down
22 changes: 22 additions & 0 deletions devenv-activity/src/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ pub fn message_with_details(
}));
}

/// Emit a standalone message with optional details and an explicit parent activity ID.
///
/// Use this when you know the parent activity ID from an external source (e.g., Nix's
/// internal log format) rather than relying on the task-local activity stack.
pub fn message_with_parent(
level: ActivityLevel,
text: impl Into<String>,
details: Option<String>,
parent: Option<u64>,
) {
// Prefer the explicit parent if provided, otherwise fall back to the stack
let parent = parent.or_else(current_activity_id);
send_activity_event(ActivityEvent::Message(Message {
id: next_id(),
level,
text: text.into(),
details,
parent,
timestamp: Timestamp::now(),
}));
}

/// Emit a SetExpected event to announce aggregate expected counts.
/// This is used by Nix to announce how many items/bytes are expected
/// before individual activities start.
Expand Down
9 changes: 9 additions & 0 deletions devenv-core/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ pub struct GlobalOptions {
)]
pub no_tui: bool,

#[arg(
long,
global = true,
env = "DEVENV_TUI_OPEN_ON_ERROR",
help = "Keep the TUI open on error for interactive review."
)]
pub tui_open_on_error: bool,

#[arg(
long,
global = true,
Expand Down Expand Up @@ -284,6 +292,7 @@ impl Default for GlobalOptions {
quiet: false,
tui: true,
no_tui: false,
tui_open_on_error: false,
log_format: None,
trace_format: TraceFormat::default(),
trace_output: None,
Expand Down
1 change: 1 addition & 0 deletions devenv-core/src/eval_op.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ mod tests {
msg: msg.to_string(),
raw_msg: None,
level: Verbosity::Warn,
parent: None,
}
}

Expand Down
21 changes: 21 additions & 0 deletions devenv-core/src/internal_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub enum InternalLog {
msg: String,
// Raw message when logging ErrorInfo
raw_msg: Option<String>,
// Parent activity ID (from Nix's internal log format)
#[serde(default)]
parent: Option<u64>,
},
Start {
id: u64,
Expand Down Expand Up @@ -215,6 +218,22 @@ mod test {
level: Verbosity::Warn,
msg: "hello".to_string(),
raw_msg: None,
parent: None,
}
);
}

#[test]
fn test_parse_log_msg_with_parent() {
let line = r#"@nix {"action":"msg","level":0,"msg":"error: something","parent":42}"#;
let log = InternalLog::parse(line).unwrap().unwrap();
assert_eq!(
log,
InternalLog::Msg {
level: Verbosity::Error,
msg: "error: something".to_string(),
raw_msg: None,
parent: Some(42),
}
);
}
Expand Down Expand Up @@ -284,6 +303,7 @@ mod test {
level: Verbosity::Error,
msg: "\u{1b}[31;1merror:\u{1b}[0m\nsomething went wrong".to_string(),
raw_msg: None,
parent: None,
};
assert!(log.is_nix_error());
}
Expand All @@ -296,6 +316,7 @@ mod test {
level: Verbosity::Error,
msg: "not an error".to_string(),
raw_msg: None,
parent: None,
};
assert!(!log.is_nix_error());
}
Expand Down
15 changes: 10 additions & 5 deletions devenv-core/src/nix_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
//! This guard-based API ensures eval scopes are always properly closed.

use devenv_activity::{
Activity, ActivityLevel, ExpectedCategory, FetchKind, message, message_with_details,
op_to_evaluate, set_expected,
Activity, ActivityLevel, ExpectedCategory, FetchKind, message_with_parent, op_to_evaluate,
set_expected,
};
use regex::Regex;
use std::collections::HashMap;
Expand Down Expand Up @@ -188,7 +188,12 @@ impl NixLogBridge {
activity_info.activity.phase(&phase);
}
}
InternalLog::Msg { level, ref msg, .. } => {
InternalLog::Msg {
level,
ref msg,
ref parent,
..
} => {
// Extract any input operation from the log for caching
if let Some(op) = EvalOp::from_internal_log(&log) {
// Notify all active observers
Expand All @@ -213,7 +218,7 @@ impl NixLogBridge {
// or is_builtin_trace() checks.
if log.is_nix_error() || log.is_builtin_trace() {
let (summary, details) = parse_nix_error(msg);
message_with_details(ActivityLevel::Error, summary, details);
message_with_parent(ActivityLevel::Error, summary, details, *parent);
error!("{msg}");
} else {
let activity_level = match level {
Expand All @@ -227,7 +232,7 @@ impl NixLogBridge {
Verbosity::Debug => ActivityLevel::Debug,
Verbosity::Vomit => ActivityLevel::Trace,
};
message(activity_level, msg);
message_with_parent(activity_level, msg, None, *parent);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions devenv-nix-backend/src/logger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ fn create_log_callback(
msg: msg.to_string(),
raw_msg: None,
level: verbosity,
parent: None,
};
bridge.process_internal_log(log);
}
Expand Down
98 changes: 97 additions & 1 deletion devenv-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct TuiConfig {
pub max_fps: u64,
/// Minimum activity level to display (activities below this level are filtered out)
pub filter_level: ActivityLevel,
/// Whether to keep the TUI open when errors occur (opt-in)
pub open_on_error: bool,
}

impl Default for TuiConfig {
Expand All @@ -45,6 +47,7 @@ impl Default for TuiConfig {
log_viewport_collapsed: 10,
max_fps: 30,
filter_level: ActivityLevel::Info,
open_on_error: false,
}
}
}
Expand Down Expand Up @@ -100,6 +103,12 @@ impl TuiApp {
self
}

/// Set whether to keep the TUI open on error for interactive review.
pub fn open_on_error(mut self, open: bool) -> Self {
self.config.open_on_error = open;
self
}

/// Run the TUI application until the backend completes.
///
/// The `backend_done` receiver signals when the backend has fully completed
Expand Down Expand Up @@ -207,6 +216,37 @@ impl TuiApp {
}
}

// If open_on_error is set and there are errors, enter ErrorPaused mode
// This keeps the TUI open for interactive review until the user exits
let error_paused = if config.open_on_error && shutdown.last_signal().is_none() {
let has_errors = activity_model
.read()
.map(|m| m.has_errors())
.unwrap_or(false);
if has_errors {
if let Ok(mut ui) = ui_state.write() {
ui.view_mode = ViewMode::ErrorPaused;
}
// Create a new shutdown for the error-paused loop
let pause_shutdown = Arc::new(Shutdown::new());
// Run the TUI in ErrorPaused mode until user presses q/Enter/Esc
let _ = run_view(
activity_model.clone(),
ui_state.clone(),
notify.clone(),
pause_shutdown,
config.clone(),
&mut pre_expand_height,
)
.await;
true
} else {
false
}
} else {
false
};

// Final render pass to ensure all drained events are displayed
// This replaces the old is_done() check that triggered shutdown AFTER rendering
//
Expand Down Expand Up @@ -300,6 +340,17 @@ impl TuiApp {
}
let _ = execute!(stderr, ResetColor);
}

// Print tip about --tui-open-on-error if not already using it
// and we didn't just come from error-paused mode
if !config.open_on_error && !error_paused {
eprintln!();
let _ = execute!(stderr, SetForegroundColor(Color::AnsiValue(245))); // Dim gray
eprintln!(
"Tip: use --tui-open-on-error to review errors interactively"
);
let _ = execute!(stderr, ResetColor);
}
}
}
}
Expand Down Expand Up @@ -370,12 +421,23 @@ fn MainView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
&& key_event.kind != KeyEventKind::Release
{
debug!("Key event: {:?}", key_event);

// Check if we're in ErrorPaused mode
let is_error_paused = ui_state
.read()
.map(|ui| matches!(ui.view_mode, ViewMode::ErrorPaused))
.unwrap_or(false);

match key_event.code {
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
// Set signal so Nix backend knows to interrupt operations
shutdown.set_last_signal(Signal::SIGINT);
shutdown.shutdown();
}
// In ErrorPaused mode: q, Enter, Esc all exit
KeyCode::Char('q') | KeyCode::Enter if is_error_paused => {
should_exit.set(true);
}
KeyCode::Char('e') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
if let Ok(mut ui) = ui_state.write()
&& let Some(activity_id) = ui.selected_activity
Expand Down Expand Up @@ -403,7 +465,9 @@ fn MainView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
}
}
KeyCode::Esc => {
if let Ok(mut ui) = ui_state.write() {
if is_error_paused {
should_exit.set(true);
} else if let Ok(mut ui) = ui_state.write() {
ui.selected_activity = None;
}
}
Expand Down Expand Up @@ -481,6 +545,38 @@ async fn run_view(
.ignore_ctrl_c()
.await
}
ViewMode::ErrorPaused => {
// Same as Main view but stays open for error review
if *pre_expand_height > 0 {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
cursor::MoveToPreviousLine(*pre_expand_height),
terminal::Clear(terminal::ClearType::FromCursorDown)
);
*pre_expand_height = 0;
}

let mut element = element! {
ContextProvider(value: Context::owned(config.clone())) {
ContextProvider(value: Context::owned(shutdown.clone())) {
ContextProvider(value: Context::owned(notify.clone())) {
ContextProvider(value: Context::owned(activity_model.clone())) {
ContextProvider(value: Context::owned(ui_state.clone())) {
MainView
}
}
}
}
}
};

element
.render_loop()
.output(Output::Stderr)
.ignore_ctrl_c()
.await
}
ViewMode::ExpandedLogs { activity_id } => {
// Calculate height before switching to expanded view
// Use a block to ensure guards are dropped before await
Expand Down
Loading
Loading