use super::prelude::*;
use crate::update::RecoveryHint;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
const MAX_BANNER_ROWS: u16 = 4;
pub fn draw_warning_banner(frame: &mut Frame, message: &str, area: Rect) {
draw_warning_banner_with_hint(frame, message, None, area);
}
pub fn banner_row_count(message: &str, hint: Option<&RecoveryHint>, width: u16) -> u16 {
if width == 0 {
return 1;
}
if hint.is_some() {
return 2;
}
let cols = (UnicodeWidthStr::width(message) as u16).saturating_add(4);
cols.div_ceil(width).clamp(1, MAX_BANNER_ROWS)
}
pub fn draw_warning_banner_with_hint(
frame: &mut Frame,
message: &str,
hint: Option<&RecoveryHint>,
area: Rect,
) {
let lines = match hint {
Some(h) => vec![
Line::from(Span::styled(
format!(" ⚠ {} ", h.friendly_summary),
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
format!(" press '{}' to run `{}` ", h.key_hint, h.command_label),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
],
None => vec![Line::from(Span::styled(
format!(" ⚠ {} ", message),
Style::default().fg(Color::Yellow),
))],
};
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn test_warning_message_format() {
let message = "Merge conflicts detected";
let formatted = format!(" ⚠ {} ", message);
assert_eq!(formatted, " ⚠ Merge conflicts detected ");
}
fn row_text(buffer: &ratatui::buffer::Buffer, y: u16) -> String {
let mut s = String::new();
for x in 0..buffer.area.width {
s.push_str(buffer.cell((x, y)).expect("cell").symbol());
}
s
}
#[test]
fn banner_with_hint_renders_friendly_summary_and_press_hint() {
let backend = TestBackend::new(80, 2);
let mut terminal = Terminal::new(backend).expect("test terminal");
let hint = RecoveryHint::jj_update_stale();
terminal
.draw(|f| {
draw_warning_banner_with_hint(
f,
"Error: jj diff --from @- --to @ exited with status: 1: \
Error: The working copy is stale (not updated since op ...)",
Some(&hint),
f.area(),
);
})
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let row0 = row_text(&buffer, 0);
let row1 = row_text(&buffer, 1);
assert!(row0.contains("jj working copy is stale"), "row0: {row0:?}");
assert!(
!row0.contains("--from @-"),
"raw subprocess error must not leak into the user-facing banner: {row0:?}"
);
assert!(row1.contains("press 'u'"), "row1: {row1:?}");
assert!(row1.contains("jj workspace update-stale"), "row1: {row1:?}");
}
#[test]
fn banner_without_hint_wraps_long_message() {
let backend = TestBackend::new(40, 4);
let mut terminal = Terminal::new(backend).expect("test terminal");
let long = "this is a deliberately long error message that exceeds forty cols and must wrap onto multiple rows";
terminal
.draw(|f| {
draw_warning_banner_with_hint(f, long, None, f.area());
})
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let full: String = (0..buffer.area.height)
.map(|y| row_text(&buffer, y))
.collect::<Vec<_>>()
.join(" ");
let normalized: String = full.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
normalized.contains("deliberately long error message that exceeds forty cols"),
"early words missing — wrap dropped content. got: {normalized:?}"
);
assert!(
normalized.contains("must wrap onto multiple rows"),
"trailing words missing — content clipped at banner boundary. got: {normalized:?}"
);
}
#[test]
fn banner_row_count_actionable_always_two_rows() {
let hint = RecoveryHint::jj_update_stale();
assert_eq!(banner_row_count("short", Some(&hint), 80), 2);
assert_eq!(banner_row_count("short", Some(&hint), 20), 2);
assert_eq!(banner_row_count("x".repeat(500).as_str(), Some(&hint), 80), 2);
}
#[test]
fn banner_row_count_unhinted_wraps_and_caps() {
assert_eq!(banner_row_count("short", None, 80), 1);
let huge = "x".repeat(200);
let rows = banner_row_count(&huge, None, 40);
assert!(rows <= 4, "must cap at MAX_BANNER_ROWS, got {rows}");
assert!(rows >= 2, "must grow beyond 1 row for long message, got {rows}");
}
#[test]
fn banner_row_count_zero_width_is_safe() {
let hint = RecoveryHint::jj_update_stale();
assert_eq!(banner_row_count("anything", None, 0), 1);
assert_eq!(banner_row_count("anything", Some(&hint), 0), 1);
}
}