From f15563c083c5692f1f3f3e5204f7d868fd2b37d4 Mon Sep 17 00:00:00 2001 From: Shane Jonas Date: Fri, 21 Aug 2026 23:00:15 -0400 Subject: [PATCH] fix: keep request navigation visible Filters could hide captured requests while the empty state claimed none existed. Clear stale filters on session changes and make Enter follow the selected response. --- CHANGELOG.md | 10 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +++-- src/app.rs | 1 + src/main.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 41 ++++++++++++++++++++++++++--- tests/app_tests.rs | 21 +++++++++++++++ 8 files changed, 138 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f9212..fb87407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-08-21 + +### Changed + +- `Enter` opens the selected response from Requests. `Ctrl-B y` copies any focused panel as Markdown. + +### Fixed + +- Request lists explain when a filter hides every row, and session changes clear stale filters. + ## [0.3.0] - 2026-08-21 ### Added diff --git a/Cargo.lock b/Cargo.lock index 7f495ce..0aa148f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "jsonrpc-debugger" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index feb25e0..d565711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jsonrpc-debugger" -version = "0.3.0" +version = "0.3.1" edition = "2021" authors = ["Shane Jonas "] description = "A terminal-based JSON-RPC debugger with interception capabilities" diff --git a/README.md b/README.md index 32434f0..415256f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ The TUI shows request history beside the selected request and response. It suppo | Change tabs or inputs | Click them | | Select a line | Click its line number | | Select a line range | Click, then Shift-click | -| Copy the focused panel as Markdown | `Enter` | +| Open the selected response | `Enter` from Requests | +| Copy details or status as Markdown | `Enter` | +| Copy any focused panel as Markdown | `Ctrl-B y` | | Open commands / keybinds | `Ctrl-B` / `Ctrl-B ?` | | Fullscreen the focused panel | `Ctrl-B z` | | Open saved sessions / start a new one | `Ctrl-B s` / `Ctrl-B n` | @@ -67,7 +69,7 @@ The TUI shows request history beside the selected request and response. It suppo | Create a request | `Ctrl-B c` | | Quit | `Ctrl-B q` or `Ctrl-C` | -The request list copies as a Markdown table. Request bodies, responses, headers, and status copy as Markdown. +The request list copies as a Markdown table with `Ctrl-B y`. Request bodies, responses, headers, and status copy as Markdown with `Enter` or `Ctrl-B y`. The inline editor supports normal Vim motions and operators such as `w`, `b`, `e`, `cw`, `dw`, `dd`, `u`, and `p`. Save with `:w`; cancel with `:q!`. diff --git a/src/app.rs b/src/app.rs index bbb3700..6069d97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -978,6 +978,7 @@ impl App { self.exchanges = exchanges; self.selected_exchange = self.exchanges.len().saturating_sub(1); self.history_scroll = None; + self.filter_text.clear(); self.session = Some(session); self.overlay = Overlay::None; self.line_selection = None; diff --git a/src/main.rs b/src/main.rs index 5eed5ca..c66fb1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,28 @@ fn copy_focused_panel( copy_to_clipboard(terminal, &markdown) } +fn enter_request_list(app: &mut App) -> bool { + if app.app_mode != AppMode::Normal + || !app.is_message_list_focused() + || app.visual_selection_active + { + return false; + } + + let visible = app.filtered_exchange_indices(); + let selected = visible + .iter() + .copied() + .find(|index| *index == app.selected_exchange) + .or_else(|| visible.first().copied()); + if let Some(selected) = selected { + app.select_exchange(selected); + app.set_focus(app::Focus::ResponseSection); + } + + true +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EditorAction { None, @@ -1049,7 +1071,8 @@ async fn run_app( let mut received_request_result = false; while let Ok(result) = runtime.request_result_receiver.try_recv() { app.notice = Some(match result { - Ok(()) => "Request sent".to_string(), + Ok(()) if app.filter_text.is_empty() => "Request sent".to_string(), + Ok(()) => "Request sent; filter is active".to_string(), Err(error) => format!("Error: {}", error), }); received_request_result = true; @@ -1355,7 +1378,7 @@ async fn run_app( app.clear_line_selection(); } KeyCode::Enter => { - if app.app_mode == AppMode::Normal { + if app.app_mode == AppMode::Normal && !enter_request_list(&mut app) { copy_focused_panel(terminal, &app)?; } } @@ -1702,6 +1725,10 @@ async fn handle_overlay_key( None => app.notice = Some("No annotation under cursor".to_string()), } } + KeyCode::Char('y') => { + app.close_overlay(); + copy_focused_panel(terminal, app)?; + } KeyCode::Char('s') => match runtime.history.list_sessions(1000) { Ok(sessions) => app.show_sessions(sessions), Err(error) => { @@ -2348,6 +2375,39 @@ mod tests { assert_eq!(app.history_scroll, Some(3)); } + #[test] + fn enter_on_request_list_focuses_the_visible_response() { + let mut app = App::new(); + for (id, method) in [(1, "first"), (2, "second")] { + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(id)), + method: Some(method.to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); + } + app.selected_exchange = 0; + app.filter_text = "second".to_string(); + + assert!(enter_request_list(&mut app)); + assert_eq!(app.selected_exchange, 1); + assert_eq!(app.focus, app::Focus::ResponseSection); + } + + #[test] + fn visual_selection_still_copies_from_request_list_focus() { + let mut app = App::new(); + app.visual_selection_active = true; + + assert!(!enter_request_list(&mut app)); + assert_eq!(app.focus, app::Focus::MessageList); + } + #[test] fn keyboard_cursor_stops_at_the_bottom_and_keeps_it_visible() { let mut app = App::new(); diff --git a/src/ui.rs b/src/ui.rs index 42ad31c..ea0a844 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -823,6 +823,7 @@ fn draw_keybind_help(f: &mut Frame) { Line::from("^B c create request ^B p pause interception"), Line::from("^B t target ^B x start/stop proxy"), Line::from("^B z fullscreen panel"), + Line::from("^B y copy focused panel as Markdown"), Line::from("^B d delete focused annotation"), Line::from("^B q quit ^B ? this help"), Line::from(""), @@ -835,7 +836,8 @@ fn draw_keybind_help(f: &mut Frame) { Line::from(""), Line::from(Span::styled("Navigation", Style::default().fg(Color::Cyan))), Line::from("↑/↓ or j/k navigate Tab focus h/l tabs / filter"), - Line::from("d/u page g/G top/bottom Enter copy Markdown"), + Line::from("d/u page g/G top/bottom"), + Line::from("Requests: Enter response Details: Enter copy Markdown"), Line::from("Details: v visual select j/k extend Esc clear"), ]; let block = Block::default() @@ -1233,7 +1235,12 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { .collect(); if filtered.is_empty() { - let empty_message = if app.is_running { + let empty_message = if !app.filter_text.is_empty() && !app.exchanges.is_empty() { + format!( + "No requests match filter {:?}. Press / then Enter to clear it.", + app.filter_text + ) + } else if app.is_running { format!( "Proxy is running on port {}. Waiting for requests...", app.proxy_config.listen_port @@ -2090,6 +2097,7 @@ fn get_keybinds_for_mode(app: &App) -> Vec { KeybindInfo::new("p", "pause", 1), KeybindInfo::new("t", "target", 1), KeybindInfo::new("x", "start/stop", 1), + KeybindInfo::new("y", "copy markdown", 1), KeybindInfo::new( "z", if app.panel_fullscreen { @@ -2119,11 +2127,17 @@ fn get_keybinds_for_mode(app: &App) -> Vec { return vec![KeybindInfo::new("Esc", "close", 1)]; } + let enter_description = + if app.app_mode == AppMode::Normal && matches!(app.focus, Focus::MessageList) { + "response" + } else { + "copy markdown" + }; let mut keybinds = vec![ KeybindInfo::new("^B", "commands", 1), KeybindInfo::new("↑↓/j/k", "navigate", 1), KeybindInfo::new("Tab", "focus", 1), - KeybindInfo::new("Enter", "copy markdown", 1), + KeybindInfo::new("Enter", enter_description, 1), KeybindInfo::new("/", "filter", 2), KeybindInfo::new("h/l", "tabs", 2), KeybindInfo::new("d/u/g/G", "scroll", 2), @@ -2689,6 +2703,27 @@ mod tests { ); } + #[test] + fn filtered_empty_list_explains_that_requests_are_hidden() { + let mut app = app_with_request(); + app.filter_text = "missing".to_string(); + let area = Rect::new(0, 0, 80, 5); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + + terminal + .draw(|frame| draw_message_list(frame, area, &app)) + .unwrap(); + + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(text.contains("No requests match filter \"missing\"")); + } + #[test] fn clicking_a_session_row_selects_it() { let mut app = App::new(); diff --git a/tests/app_tests.rs b/tests/app_tests.rs index 04e0bb2..aa3f288 100644 --- a/tests/app_tests.rs +++ b/tests/app_tests.rs @@ -658,6 +658,27 @@ fn session_name_prompts_use_the_shared_input_buffer() { assert_eq!(app.session.unwrap().name, "Refunds"); } +#[test] +fn activating_a_session_clears_the_previous_filter() { + let mut app = App::new(); + app.filter_text = "old-filter".to_string(); + + app.activate_session( + SessionSummary { + id: "session".to_string(), + name: "Session".to_string(), + target: "http://node".to_string(), + created_at_ms: 1, + updated_at_ms: 1, + exchange_count: 0, + }, + Vec::new(), + Vec::new(), + ); + + assert!(app.filter_text.is_empty()); +} + #[test] fn annotation_prompt_requires_an_active_visual_selection() { let mut app = App::new();