diff --git a/AGENTS.md b/AGENTS.md index 9524027b..6e18ef46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ Repository-managed agent integrations: - `Comment` - typing a comment (Ctrl-S saves, Ctrl-C cancels) - `Search` - after pressing `/`, search pattern entry - `Help` - showing help popup +- `MessageDetails` - showing the complete current error in a scrollable full-screen view - `Confirm` - Y/N confirmation dialog - `CommitSelect` - selecting commits to review - `VisualSelect` - visual mode for range comments diff --git a/README.md b/README.md index 9eb50c25..0f7d2d4e 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference. | `[` / `]` | Previous / next hunk | | `m` / `M` | Next / previous comment | | `/` | Search the diff, or search help while help is open (case-insensitive) | +| `:messages` | Open full details for the current error | | `c` / `C` | Add line / file comment | | `v` / `V` | Visual mode (range comment) | | `r` | Toggle file reviewed | diff --git a/src/app/init.rs b/src/app/init.rs index 8012569a..38ac210b 100644 --- a/src/app/init.rs +++ b/src/app/init.rs @@ -458,9 +458,11 @@ impl App { help_state: HelpState::default(), command_buffer: String::new(), command_completion: None, + command_return_mode: InputMode::Normal, search_buffer: String::new(), last_search_pattern: None, search_return_mode: InputMode::Normal, + overlay_return_mode: InputMode::Normal, comment_buffer: String::new(), comment_cursor: 0, comment_vim_enabled: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 9bd47c06..f5403dc3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -540,6 +540,8 @@ pub enum InputMode { Command, Search, Help, + /// Scrollable full-screen view for the complete current error message. + MessageDetails, Confirm, CommitSelect, VisualSelect, @@ -1028,9 +1030,11 @@ pub struct App { pub help_state: HelpState, pub command_buffer: String, pub(crate) command_completion: Option, + pub(crate) command_return_mode: InputMode, pub search_buffer: String, pub last_search_pattern: Option, pub(crate) search_return_mode: InputMode, + pub(crate) overlay_return_mode: InputMode, pub comment_buffer: String, pub comment_cursor: usize, /// Config `comment_vim`: vim modal editing in the comment box. diff --git a/src/app/modes.rs b/src/app/modes.rs index 1c4445c9..1b87ada2 100644 --- a/src/app/modes.rs +++ b/src/app/modes.rs @@ -25,6 +25,13 @@ impl App { message_type: MessageType, ttl: Option, ) { + if self.input_mode == InputMode::MessageDetails { + if message_type == MessageType::Error { + self.help_state.scroll_offset = 0; + } else { + self.input_mode = self.overlay_return_mode; + } + } self.message = Some(Message { content: msg.into(), message_type, @@ -47,13 +54,14 @@ impl App { } pub fn enter_command_mode(&mut self) { + self.command_return_mode = self.input_mode; self.input_mode = InputMode::Command; self.command_buffer.clear(); self.command_completion = None; } pub fn exit_command_mode(&mut self) { - self.input_mode = InputMode::Normal; + self.input_mode = self.command_return_mode; self.command_buffer.clear(); self.command_completion = None; } @@ -73,12 +81,30 @@ impl App { self.input_mode == InputMode::Search && self.search_return_mode == InputMode::Help } - pub fn toggle_help(&mut self) { - if self.input_mode == InputMode::Help { - self.input_mode = InputMode::Normal; - } else { - self.input_mode = InputMode::Help; + pub fn open_message_details(&mut self) { + if self + .message + .as_ref() + .is_some_and(|message| message.message_type == MessageType::Error) + { + self.overlay_return_mode = self.input_mode; + self.input_mode = InputMode::MessageDetails; self.help_state.scroll_offset = 0; + } else { + self.set_message("No current error"); + } + } + + pub fn toggle_help(&mut self) { + match self.input_mode { + InputMode::Help | InputMode::MessageDetails => { + self.input_mode = self.overlay_return_mode; + } + _ => { + self.overlay_return_mode = self.input_mode; + self.input_mode = InputMode::Help; + self.help_state.scroll_offset = 0; + } } } diff --git a/src/app/pr.rs b/src/app/pr.rs index 74c0d9ba..67428ddf 100644 --- a/src/app/pr.rs +++ b/src/app/pr.rs @@ -750,9 +750,19 @@ impl App { self.pr_tab.apply_canonical(canonical.clone()); self.forge_repository = Some(canonical); self.canonical_resolved = true; + let error = result.as_ref().err().cloned(); self.pr_tab.apply_initial_load(result); + if let Some(error) = error { + self.set_error(error); + } + } + PrLoadEvent::LoadMore(result) => { + let error = result.as_ref().err().cloned(); + self.pr_tab.apply_load_more(result); + if let Some(error) = error { + self.set_error(error); + } } - PrLoadEvent::LoadMore(result) => self.pr_tab.apply_load_more(result), } } self.pr_tab.clamp_cursor(); diff --git a/src/app/tests/target_selector_tests.rs b/src/app/tests/target_selector_tests.rs index 3df2f783..3c1ca89d 100644 --- a/src/app/tests/target_selector_tests.rs +++ b/src/app/tests/target_selector_tests.rs @@ -1976,6 +1976,29 @@ fn should_apply_initial_load_event_to_pr_tab() { assert_eq!(app.pr_tab.view().rows[0].summary.number, 7); } +#[test] +fn should_surface_initial_pr_list_error_to_message_bar() { + let mut app = build_app(); + let repository = ForgeRepository::github("github.com", "agavra", "tuicr"); + app.forge_repository = Some(repository.clone()); + app.pr_tab = PullRequestsTab::new(Some(repository.clone())); + app.pr_tab.start_initial_load(); + let (tx, rx) = std::sync::mpsc::channel(); + app.pr_load_rx = Some(rx); + tx.send(PrLoadEvent::Initial { + canonical: repository, + result: Err("forge API response body".to_string()), + }) + .unwrap(); + drop(tx); + + app.poll_pr_load_events(); + + let message = app.message.as_ref().expect("expected PR-list error"); + assert_eq!(message.message_type, MessageType::Error); + assert_eq!(message.content, "forge API response body"); +} + #[test] fn should_promote_app_forge_repository_to_canonical_on_initial_load() { // given — origin is a fork; canonical from the background thread is upstream @@ -2275,6 +2298,7 @@ fn should_return_false_when_cancelling_with_no_in_flight_open() { fn should_surface_pr_open_error_to_message_bar_when_done_event_carries_error() { // given an app waiting on a synthetic open let mut app = build_app(); + app.input_mode = InputMode::CommitSelect; app.forge_repository = Some(ForgeRepository::github("github.com", "agavra", "tuicr")); app.pr_tab = loaded_pr_tab(vec![sample_pr(42, "boom")]); app.target_tab = TargetTab::PullRequests; @@ -2304,6 +2328,13 @@ fn should_surface_pr_open_error_to_message_bar_when_done_event_carries_error() { .expect("expected an error message on the bar"); assert!(matches!(msg.message_type, MessageType::Error)); assert!(msg.content.contains("auth failed"), "got {msg:?}"); + + app.enter_command_mode(); + app.command_buffer = "messages".to_string(); + crate::handler::handle_command_action(&mut app, crate::input::Action::SubmitInput); + assert_eq!(app.input_mode, InputMode::MessageDetails); + app.toggle_help(); + assert_eq!(app.input_mode, InputMode::CommitSelect); } #[test] diff --git a/src/handler.rs b/src/handler.rs index 9f56b1ce..228597a3 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -34,6 +34,7 @@ const COMMAND_SPECS: &[CommandSpec] = &[ ), CommandSpec::new(&["clearc"], CommandKind::Clear(ClearScope::CommentsOnly)), CommandSpec::new(&["help", "h"], CommandKind::Help), + CommandSpec::new(&["messages"], CommandKind::MessageDetails), CommandSpec::new(&["version"], CommandKind::Version), CommandSpec::new(&["update"], CommandKind::Update), CommandSpec::new(&["set wrap"], CommandKind::SetWrap), @@ -109,6 +110,7 @@ enum CommandKind { Export, Clear(ClearScope), Help, + MessageDetails, Version, Update, SetWrap, @@ -152,7 +154,7 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) { let over_diff = app.diff_area.is_some_and(|r| r.contains(pos)); let over_commit_list = app.commit_list_inner_area.is_some_and(|r| r.contains(pos)); match app.input_mode { - InputMode::Help => handle_help_action(app, action), + InputMode::Help | InputMode::MessageDetails => handle_help_action(app, action), InputMode::CommitSelect | InputMode::Normal if over_commit_list => { wheel_commit_list(app, scroll_up); } @@ -778,6 +780,11 @@ fn dispatch_command(app: &mut App, kind: CommandKind) -> CommandAfterDispatch { app.toggle_help(); CommandAfterDispatch::KeepMode } + CommandKind::MessageDetails => { + app.exit_command_mode(); + app.open_message_details(); + CommandAfterDispatch::KeepMode + } CommandKind::Version => { app.set_message(format!("tuicr v{}", env!("CARGO_PKG_VERSION"))); CommandAfterDispatch::ExitCommandMode @@ -1119,6 +1126,7 @@ pub fn handle_commit_select_action(app: &mut App, action: Action) { match action { Action::TargetSelectorTabNext => app.cycle_target_tab(true), Action::TargetSelectorTabPrev => app.cycle_target_tab(false), + Action::EnterCommandMode => app.enter_command_mode(), Action::Quit => app.should_quit = true, Action::ExitMode => { // Esc during an in-flight PR open aborts the load and stays diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 589e6a2a..36c7fff2 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -145,6 +145,10 @@ pub fn map_key_to_action(key: KeyEvent, mode: InputMode, leader_key: char) -> Ac InputMode::Search => map_search_mode(key), InputMode::Comment => map_comment_mode(key), InputMode::Help => map_help_mode(key), + InputMode::MessageDetails => match map_help_mode(key) { + Action::EnterSearchMode | Action::SearchNext | Action::SearchPrev => Action::None, + action => action, + }, InputMode::Confirm => map_confirm_mode(key), InputMode::CommitSelect => map_commit_select_mode(key), InputMode::VisualSelect => map_visual_mode(key), @@ -400,6 +404,7 @@ fn map_commit_select_mode(key: KeyEvent) -> Action { (KeyCode::Enter, KeyModifiers::NONE) => Action::ConfirmCommitSelect, (KeyCode::Esc, KeyModifiers::NONE) => Action::ExitMode, (KeyCode::Char('q'), KeyModifiers::NONE) => Action::Quit, + (KeyCode::Char(':'), _) => Action::EnterCommandMode, (KeyCode::Tab, KeyModifiers::NONE) => Action::TargetSelectorTabNext, (KeyCode::BackTab, _) => Action::TargetSelectorTabPrev, (KeyCode::Char('/'), _) => Action::BeginTargetFilter, @@ -494,6 +499,28 @@ mod tests { assert_eq!(map_help_mode(key_shift('N')), Action::SearchPrev); } + #[test] + fn should_reuse_help_navigation_without_search_for_message_details() { + assert_eq!( + map_key_to_action( + key(KeyCode::Char('j')), + InputMode::MessageDetails, + DEFAULT_LEADER_KEY, + ), + Action::CursorDown(1) + ); + for key in [ + key(KeyCode::Char('/')), + key(KeyCode::Char('n')), + key_shift('N'), + ] { + assert_eq!( + map_key_to_action(key, InputMode::MessageDetails, DEFAULT_LEADER_KEY), + Action::None + ); + } + } + #[test] fn should_map_lowercase_g_to_go_to_top_in_normal_mode() { let action = map_normal_mode(key(KeyCode::Char('g')), DEFAULT_LEADER_KEY); @@ -669,6 +696,12 @@ mod tests { assert_eq!(action, Action::BeginTargetFilter); } + #[test] + fn should_map_colon_to_command_mode_in_commit_select_mode() { + let action = map_commit_select_mode(key(KeyCode::Char(':'))); + assert_eq!(action, Action::EnterCommandMode); + } + #[test] fn should_map_r_to_review_requested_filter_in_commit_select_mode() { // given / when diff --git a/src/main.rs b/src/main.rs index 62bca14a..58cfc58e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -753,7 +753,7 @@ fn main() -> anyhow::Result<()> { fn dispatch_action(app: &mut App, action: Action) { match app.input_mode { - InputMode::Help => handle_help_action(app, action), + InputMode::Help | InputMode::MessageDetails => handle_help_action(app, action), InputMode::Command => handle_command_action(app, action), InputMode::Search => handle_search_action(app, action), InputMode::Comment => handle_comment_action(app, action), diff --git a/src/ui/app_layout.rs b/src/ui/app_layout.rs index 1704dbf1..69708ac9 100644 --- a/src/ui/app_layout.rs +++ b/src/ui/app_layout.rs @@ -22,9 +22,33 @@ pub fn render(frame: &mut Frame, app: &mut App) { frame.area(), ); - // Special handling for commit selection mode - if app.input_mode == InputMode::CommitSelect { + if app.input_mode == InputMode::MessageDetails { + help_popup::render_message_details(frame, app); + return; + } + + let selector_background = app.input_mode == InputMode::CommitSelect + || (app.input_mode == InputMode::Command + && app.command_return_mode == InputMode::CommitSelect) + || (app.input_mode == InputMode::Help + && app.overlay_return_mode == InputMode::CommitSelect) + || (app.searching_help() && app.overlay_return_mode == InputMode::CommitSelect); + if selector_background { render_commit_select(frame, app); + let area = frame.area(); + let footer = Rect::new( + area.x, + area.bottom().saturating_sub(1), + area.width, + area.height.min(1), + ); + if matches!(app.input_mode, InputMode::Command | InputMode::Search) { + status_bar::render_status_bar(frame, app, footer); + status_bar::render_command_completion_popup(frame, app, footer); + } + if app.input_mode == InputMode::Help || app.searching_help() { + help_popup::render_help(frame, app); + } return; } diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 2104a4fd..287dad71 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -3,12 +3,65 @@ use ratatui::{ layout::{Constraint, Flex, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use crate::app::App; use crate::ui::styles; +pub fn render_message_details(frame: &mut Frame, app: &mut App) { + let Some(message) = app.message.as_ref() else { + return; + }; + let content = message.content.clone(); + let theme = &app.theme; + let area = frame.area(); + + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .style(styles::panel_style(theme)) + .border_style(styles::border_style(theme, true)); + let inner = block.inner(area); + + let paragraph = Paragraph::new(content.as_str()) + .style( + Style::default() + .fg(theme.message_error_fg) + .bg(theme.panel_bg), + ) + .wrap(Wrap { trim: false }); + app.help_state.total_lines = paragraph.line_count(inner.width); + app.help_state.viewport_height = inner.height as usize; + let max_offset = app + .help_state + .total_lines + .saturating_sub(app.help_state.viewport_height); + app.help_state.scroll_offset = app.help_state.scroll_offset.min(max_offset); + let scroll_indicator = match ( + app.help_state.scroll_offset > 0, + app.help_state.scroll_offset < max_offset, + ) { + (true, true) => " ↑↓", + (true, false) => " ↑", + (false, true) => " ↓", + (false, false) => "", + }; + frame.render_widget( + block.title(format!( + " Messages (j/k scroll, q/Esc close){scroll_indicator} " + )), + area, + ); + frame.render_widget( + paragraph.scroll(( + app.help_state.scroll_offset.min(u16::MAX as usize) as u16, + 0, + )), + inner, + ); +} + pub fn render_help(frame: &mut Frame, app: &mut App) { let theme = &app.theme; // Center over the diff pane (matches the submit-modal anchoring) so the @@ -615,6 +668,13 @@ pub fn render_help(frame: &mut Frame, app: &mut App) { ), Span::raw("Open this help screen"), ]), + Line::from(vec![ + Span::styled( + " :messages ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("Open full details for the current error"), + ]), Line::from(vec![ Span::styled( " :stage ", diff --git a/src/ui/selector.rs b/src/ui/selector.rs index 17a4da7c..4cb720b3 100644 --- a/src/ui/selector.rs +++ b/src/ui/selector.rs @@ -877,6 +877,60 @@ mod selector_render_snapshot_tests { ); } + #[test] + fn should_render_full_screen_messages_from_target_selector() { + let mut app = make_app(vec![commit(0)]); + app.target_tab = crate::app::TargetTab::PullRequests; + app.set_error("forge API failure detail"); + app.enter_command_mode(); + app.command_buffer = "messages".to_string(); + crate::handler::handle_command_action(&mut app, crate::input::Action::SubmitInput); + + let buffer = draw(&mut app); + let rendered = (0..buffer.area.height) + .map(|y| row_text(&buffer, y)) + .collect::>() + .join("\n"); + + let width = buffer.area.width; + let height = buffer.area.height; + assert_eq!(buffer[(0, 0)].symbol(), "┌"); + assert_eq!(buffer[(width - 1, 0)].symbol(), "┐"); + assert_eq!(buffer[(0, height - 1)].symbol(), "└"); + assert_eq!(buffer[(width - 1, height - 1)].symbol(), "┘"); + assert!(!row_text(&buffer, TAB_STRIP_ROW).contains("Pull Requests")); + assert!(rendered.contains("Messages"), "got:\n{rendered}"); + assert!( + rendered.contains("forge API failure detail"), + "got:\n{rendered}" + ); + } + + #[test] + fn should_keep_target_selector_behind_command_and_help_overlays() { + let mut app = make_app(vec![commit(0)]); + app.target_tab = crate::app::TargetTab::PullRequests; + + app.enter_command_mode(); + app.command_buffer = "mes".to_string(); + let command_buffer = draw(&mut app); + assert!(row_text(&command_buffer, TAB_STRIP_ROW).contains("Pull Requests")); + assert!(row_text(&command_buffer, command_buffer.area.height - 1).contains(":mes")); + crate::handler::handle_command_action(&mut app, crate::input::Action::ExitMode); + assert_eq!(app.input_mode, InputMode::CommitSelect); + + app.toggle_help(); + let help_buffer = draw(&mut app); + let rendered = (0..help_buffer.area.height) + .map(|y| row_text(&help_buffer, y)) + .collect::>() + .join("\n"); + assert!(row_text(&help_buffer, TAB_STRIP_ROW).contains("Pull Requests")); + assert!(rendered.contains("Help"), "got:\n{rendered}"); + app.toggle_help(); + assert_eq!(app.input_mode, InputMode::CommitSelect); + } + #[test] fn should_render_loading_state_in_tab_strip_status_slot_when_pr_load_in_flight() { // given diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index 2603a2dc..1a33cdfa 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -7,6 +7,7 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; +use unicode_width::UnicodeWidthStr; use crate::app::{App, DiffSource, InputMode, Message, MessageType}; use crate::theme::Theme; @@ -23,8 +24,13 @@ pub fn build_message_span(message: Option<&Message>, theme: &Theme) -> (Span<'st MessageType::Warning => (theme.message_warning_fg, theme.message_warning_bg), MessageType::Error => (theme.message_error_fg, theme.message_error_bg), }; - let content = format!(" {} ", msg.content); - let width = content.len(); + let detail = msg.content.replace(['\n', '\r'], " "); + let content = if msg.message_type == MessageType::Error { + format!(" [:messages] {detail} ") + } else { + format!(" {detail} ") + }; + let width = content.width(); ( Span::styled( content, @@ -43,7 +49,7 @@ pub fn build_right_aligned_spans<'a>( message_width: usize, total_width: usize, ) -> Vec> { - let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum(); + let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum(); let padding_width = total_width.saturating_sub(left_width + message_width); let padding = Span::raw(" ".repeat(padding_width)); @@ -241,6 +247,7 @@ pub fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { InputMode::Search => " SEARCH ".to_string(), InputMode::Comment => " COMMENT ".to_string(), InputMode::Help => " HELP ".to_string(), + InputMode::MessageDetails => " ERROR ".to_string(), InputMode::Confirm => " CONFIRM ".to_string(), InputMode::CommitSelect => " SELECT ".to_string(), InputMode::VisualSelect => { @@ -274,6 +281,7 @@ pub fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { InputMode::Search => Cow::Borrowed(" \u{21b5} search \u{00b7} esc cancel"), InputMode::Comment => Cow::Borrowed(" ctrl-s save \u{00b7} esc cancel"), InputMode::Help => Cow::Borrowed(" / search · n/N match · q/?/esc close"), + InputMode::MessageDetails => Cow::Borrowed(" j/k scroll · q/esc close"), InputMode::Confirm => Cow::Borrowed(" y yes \u{00b7} n no"), InputMode::CommitSelect => Cow::Borrowed( " j/k navigate \u{00b7} space select \u{00b7} \u{21b5} confirm \u{00b7} esc back", @@ -640,12 +648,167 @@ mod pr_header_snapshot_tests { terminal.backend().buffer().clone() } + fn draw_app(app: &mut App, width: u16, height: u16) -> Buffer { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, app)) + .expect("draw frame"); + terminal.backend().buffer().clone() + } + fn row_text(buffer: &Buffer, y: u16) -> String { (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol().to_string()) .collect() } + #[test] + fn should_make_full_long_error_accessible_from_status_bar() { + let mut app = build_pr_app(pr_source(false, false)); + app.set_error( + "Submit failed: GitHub command failed: gh: Unprocessable Entity (HTTP 422)\n\ + {\"message\":\"Unprocessable Entity\",\"errors\":[\"Review Can not approve your own pull request\"]}", + ); + + let status = draw_app(&mut app, 60, 12); + assert!( + row_text(&status, 11).contains("[:messages]"), + "status bar should advertise full error details" + ); + + app.open_message_details(); + let buffer = draw_app(&mut app, 60, 12); + let rendered = (0..buffer.area.height) + .map(|y| row_text(&buffer, y)) + .collect::>() + .join("\n"); + let compact = rendered + .replace('│', " ") + .split_whitespace() + .collect::>() + .join(" "); + + assert!( + compact.contains("Submit failed: GitHub command failed: gh: Unprocessable Entity"), + "error prefix should remain visible, got:\n{rendered}" + ); + + app.help_scroll_to_bottom(); + let buffer = draw_app(&mut app, 60, 12); + let rendered = (0..buffer.area.height) + .map(|y| row_text(&buffer, y)) + .collect::>() + .join("\n"); + let compact = rendered + .replace('│', " ") + .split_whitespace() + .collect::>() + .join(" "); + assert!( + compact.contains("Review Can not approve your own pull request"), + "full error detail should remain reachable, got:\n{rendered}" + ); + } + + #[test] + fn should_scroll_to_end_of_oversized_error_details() { + let mut app = build_pr_app(pr_source(false, false)); + app.set_error(format!( + "Submit failed: {}UNIQUE_END", + "long error ".repeat(200) + )); + app.open_message_details(); + + let _ = draw_app(&mut app, 60, 12); + app.help_scroll_to_bottom(); + let buffer = draw_app(&mut app, 60, 12); + let rendered = (0..buffer.area.height) + .map(|y| row_text(&buffer, y)) + .collect::>() + .join("\n"); + + assert!( + rendered.contains("UNIQUE_END"), + "last error detail should be reachable, got:\n{rendered}" + ); + } + + #[test] + fn should_scroll_error_details_with_mouse_wheel() { + let mut app = build_pr_app(pr_source(false, false)); + app.set_error(format!("failed: {}", "long error ".repeat(200))); + app.open_message_details(); + let _ = draw_app(&mut app, 60, 12); + + crate::handler::handle_mouse_event( + &mut app, + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: crossterm::event::KeyModifiers::NONE, + }, + ); + + assert!(app.help_state.scroll_offset > 0); + } + + #[test] + fn should_open_error_details_with_messages_command_and_close_to_normal_mode() { + let mut app = build_pr_app(pr_source(false, false)); + app.input_mode = InputMode::Command; + app.command_buffer = "messages".to_string(); + app.set_error("failed to load target"); + + crate::handler::handle_command_action(&mut app, crate::input::Action::SubmitInput); + assert_eq!(app.input_mode, InputMode::MessageDetails); + app.toggle_help(); + + assert_eq!(app.input_mode, InputMode::Normal); + } + + #[test] + fn should_reset_scroll_when_another_error_replaces_the_open_message() { + let mut app = build_pr_app(pr_source(false, false)); + app.set_error(format!("failed: {}", "long error ".repeat(200))); + app.open_message_details(); + let _ = draw_app(&mut app, 60, 12); + app.help_scroll_to_bottom(); + assert!(app.help_state.scroll_offset > 0); + + app.set_error("replacement error"); + + assert_eq!(app.input_mode, InputMode::MessageDetails); + assert_eq!(app.help_state.scroll_offset, 0); + } + + #[test] + fn should_report_when_messages_command_has_no_current_error() { + let mut app = build_pr_app(pr_source(false, false)); + app.input_mode = InputMode::Command; + app.command_buffer = "messages".to_string(); + + crate::handler::handle_command_action(&mut app, crate::input::Action::SubmitInput); + + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!( + app.message.as_ref().map(|message| message.content.as_str()), + Some("No current error") + ); + } + + #[test] + fn should_close_error_details_when_a_non_error_replaces_the_message() { + let mut app = build_pr_app(pr_source(false, false)); + app.set_error("request failed"); + app.open_message_details(); + + app.set_message("request recovered"); + + assert_eq!(app.input_mode, InputMode::Normal); + } + #[test] fn should_render_pr_mode_header_with_slug_number_and_title() { // given a PR-mode app for agavra/tuicr#125