From 86601349249e0bbb3cb7fb3ec99a6ad64c6d80ea Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 16:03:05 +0000 Subject: [PATCH 1/9] Results line highlighting. --- src/app/clipboard.rs | 1 + src/app/filters.rs | 1 + src/app/keymap.rs | 1 + src/app/mod.rs | 31 ++++++++++++++++++++++++++++--- src/app/state.rs | 2 +- src/main.rs | 1 + src/ui/mod.rs | 1 + src/ui/results.rs | 22 ++++++++++++++++++++-- src/ui/styles.rs | 17 +++++++++++++++++ 9 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index 357e103..0ab4bbd 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -68,6 +68,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, diff --git a/src/app/filters.rs b/src/app/filters.rs index db5c9c4..8e49811 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -234,6 +234,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, diff --git a/src/app/keymap.rs b/src/app/keymap.rs index afeabe8..909ee02 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -272,6 +272,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 853dd73..826933d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -182,7 +182,14 @@ impl App { } fn results_up(&mut self) { - self.state.results_scroll = self.state.results_scroll.saturating_sub(1); + if self.state.results_selected > 0 { + self.state.results_selected -= 1; + } + + // Ensure scroll keeps the selected row visible at the top + if self.state.results_selected < self.state.results_scroll { + self.state.results_scroll = self.state.results_selected; + } } fn results_total_lines(&self) -> usize { @@ -191,8 +198,25 @@ impl App { fn results_down(&mut self) { let total = self.results_total_lines(); - if self.state.results_scroll + 1 < total { - self.state.results_scroll += 1; + if self.state.results_selected + 1 < total { + self.state.results_selected += 1; + } + + // Assume a fixed visible height of 4 rows for clamping purposes. + // This keeps the selected row within a small window while navigating. + let visible_rows = 4usize; + if self.state.results_selected >= self.state.results_scroll + visible_rows { + self.state.results_scroll = self + .state + .results_selected + .saturating_add(1) + .saturating_sub(visible_rows); + } + + // Also ensure we don't scroll past the last line + let max_scroll = total.saturating_sub(visible_rows); + if self.state.results_scroll > max_scroll { + self.state.results_scroll = max_scroll; } } @@ -489,6 +513,7 @@ mod tests { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, diff --git a/src/app/state.rs b/src/app/state.rs index a88716d..58baf1e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -34,7 +34,7 @@ pub struct AppState { pub dots: usize, pub last_dots: Instant, pub results_scroll: usize, - + pub results_selected: usize, pub tail_mode: bool, pub status_message: Option, diff --git a/src/main.rs b/src/main.rs index 2af0915..0f90151 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ fn main() -> Result<(), Box> { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab4a117..35684bb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -465,6 +465,7 @@ mod ui_tests { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, diff --git a/src/ui/results.rs b/src/ui/results.rs index f902f68..a40132c 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -22,6 +22,8 @@ impl App { return; } + let results_focused = self.state.focus == crate::app::Focus::Results; + // Flatten entries into raw lines (no manual wrapping). let mut raw_lines: Vec = Vec::new(); for entry in &self.state.lines { @@ -50,6 +52,8 @@ impl App { for (i, line) in raw_lines[start..end].iter().enumerate() { let y = text_area.y + i as u16; + let global_idx = start + i; + let is_selected = results_focused && global_idx == self.state.results_selected; let expanded = if line.contains('\t') { line.replace('\t', " ") @@ -79,10 +83,19 @@ impl App { // Everything after the timestamp (including the space if present) let rest: String = chars.collect(); - let ts_style = theme.results_timestamp; + let ts_style = if is_selected { + theme.results_selected + } else { + theme.results_timestamp + }; let spans = if rest.is_empty() { vec![Span::styled(ts, ts_style)] + } else if is_selected { + vec![ + Span::styled(ts, ts_style), + Span::styled(rest, theme.results_selected), + ] } else { vec![Span::styled(ts, ts_style), Span::raw(rest)] }; @@ -98,7 +111,11 @@ impl App { ); } else { // No special timestamp; render the whole line normally. - Line::from(expanded.as_str()).render( + let mut line = Line::from(expanded.as_str()); + if is_selected { + line = line.style(theme.results_selected); + } + line.render( Rect { x: text_area.x, y, @@ -166,6 +183,7 @@ mod tests { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, diff --git a/src/ui/styles.rs b/src/ui/styles.rs index b153468..7af54b3 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -31,6 +31,7 @@ pub struct Theme { pub presets_hint: Style, pub cursor: Style, pub results_timestamp: Style, + pub results_selected: Style, } impl Theme { @@ -84,6 +85,10 @@ impl Theme { .fg(Color::Rgb(100, 180, 180)) .bg(Color::Rgb(5, 5, 5)) .add_modifier(Modifier::BOLD), + results_selected: Style::default() + .fg(Color::White) + .bg(Color::Rgb(50, 50, 50)) + .add_modifier(Modifier::BOLD), } } @@ -151,6 +156,12 @@ impl Theme { .fg(Color::Rgb(0, 100, 180)) .bg(bg) .add_modifier(Modifier::BOLD); + + t.results_selected = Style::default() + .fg(Color::White) + .bg(Color::Rgb(50, 50, 50)) + .add_modifier(Modifier::BOLD); + t } @@ -233,6 +244,12 @@ impl Theme { .fg(Color::Rgb(0, 180, 180)) .bg(dark_bg) .add_modifier(Modifier::BOLD); + + t.results_selected = Style::default() + .fg(Color::White) + .bg(Color::Rgb(50, 50, 50)) + .add_modifier(Modifier::BOLD); + t } } From 4b2b88755d8a98927e8436a83ace1d5dc5d49613 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 16:12:30 +0000 Subject: [PATCH 2/9] Results line in popup. --- src/app/clipboard.rs | 2 + src/app/filters.rs | 2 + src/app/keymap.rs | 97 +++++++++++++++++++++++++++++++++++++++++++- src/app/mod.rs | 2 + src/app/state.rs | 4 ++ src/main.rs | 2 + src/ui/mod.rs | 71 ++++++++++++++++++++++++++++++++ src/ui/results.rs | 36 ++++++++++++++++ 8 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index 0ab4bbd..f6125c5 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -79,6 +79,8 @@ mod tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { diff --git a/src/app/filters.rs b/src/app/filters.rs index 8e49811..a79a576 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -246,6 +246,8 @@ mod tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { diff --git a/src/app/keymap.rs b/src/app/keymap.rs index 909ee02..35905b2 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -21,6 +21,17 @@ impl App { self.handle_load_filter_popup_key(key_event.code); return Ok(()); } + if self.state.results_detail_popup_open { + match key_event.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char(' ') => { + // Close the results detail popup on Esc, Enter, or Space. + self.state.results_detail_popup_open = false; + self.state.results_detail_selected_line = None; + } + _ => {} + } + return Ok(()); + } match key_event.code { // q should NOT quit while editing or while group search is active @@ -117,9 +128,13 @@ impl App { } } - // Enter: start/stop editing, or activate Search button + // Enter: start/stop editing, or activate Search button, or open results detail popup KeyCode::Enter => { - if self.state.focus == Focus::Filter + if !self.state.editing && self.state.focus == Focus::Results { + // Open results detail popup for the currently selected results line. + self.state.results_detail_popup_open = true; + self.state.results_detail_selected_line = Some(self.state.results_selected); + } else if self.state.focus == Focus::Filter && self.state.filter_field == FilterField::Search && !self.state.editing { @@ -145,6 +160,12 @@ impl App { Focus::Results => self.results_down(), }, + // Open results detail popup with Space on selected line + KeyCode::Char(' ') if !self.state.editing && self.state.focus == Focus::Results => { + self.state.results_detail_popup_open = true; + self.state.results_detail_selected_line = Some(self.state.results_selected); + } + // Copy results to clipboard (Results pane, not editing) KeyCode::Char('y') if !self.state.editing && self.state.focus == Focus::Results => { self.copy_results_to_clipboard(); @@ -284,6 +305,8 @@ mod tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { @@ -338,4 +361,74 @@ mod tests { app.handle_key_event(key(KeyCode::Char('T'))).unwrap(); assert_eq!(app.state.theme_name, "dark"); } + + #[test] + fn enter_opens_results_detail_popup_for_selected_line() { + let mut app = app_with_filter_query(""); + // Move focus to Results and simulate having some lines + app.state.focus = Focus::Results; + app.state.lines = vec![ + "line 0".to_string(), + "line 1".to_string(), + "line 2".to_string(), + ]; + app.state.results_selected = 1; + + // Press Enter: should open the popup for the selected line + app.handle_key_event(key(KeyCode::Enter)).unwrap(); + + assert!(app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_selected_line, Some(1)); + } + + #[test] + fn space_opens_results_detail_popup_for_selected_line() { + let mut app = app_with_filter_query(""); + app.state.focus = Focus::Results; + app.state.lines = vec![ + "l0".to_string(), + "l1".to_string(), + ]; + app.state.results_selected = 0; + + // Press Space: should open the popup for the selected line + app.handle_key_event(key(KeyCode::Char(' '))).unwrap(); + + assert!(app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_selected_line, Some(0)); + } + + #[test] + fn enter_space_esc_close_results_detail_popup() { + let mut app = app_with_filter_query(""); + app.state.focus = Focus::Results; + app.state.lines = vec!["only".to_string()]; + app.state.results_selected = 0; + + // Open popup first + app.handle_key_event(key(KeyCode::Enter)).unwrap(); + assert!(app.state.results_detail_popup_open); + + // Close with Enter + app.handle_key_event(key(KeyCode::Enter)).unwrap(); + assert!(!app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_selected_line, None); + + // Open again + app.handle_key_event(key(KeyCode::Char(' '))).unwrap(); + assert!(app.state.results_detail_popup_open); + + // Close with Space + app.handle_key_event(key(KeyCode::Char(' '))).unwrap(); + assert!(!app.state.results_detail_popup_open); + + // Open again + app.handle_key_event(key(KeyCode::Char(' '))).unwrap(); + assert!(app.state.results_detail_popup_open); + + // Close with Esc + app.handle_key_event(key(KeyCode::Esc)).unwrap(); + assert!(!app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_selected_line, None); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 826933d..2d67a4f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -525,6 +525,8 @@ mod tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { diff --git a/src/app/state.rs b/src/app/state.rs index 58baf1e..1833963 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -45,4 +45,8 @@ pub struct AppState { pub save_filter_name: String, pub load_filter_popup_open: bool, pub load_filter_selected: usize, + + // Results detail popup: whether it's open and which line is shown + pub results_detail_popup_open: bool, + pub results_detail_selected_line: Option, } diff --git a/src/main.rs b/src/main.rs index 0f90151..f762067 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,8 @@ fn main() -> Result<(), Box> { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; let mut app = App { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 35684bb..8f8d2f5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -417,6 +417,75 @@ impl Widget for &App { buf, ); } + + if self.state.results_detail_popup_open { + // Results detail popup showing the currently selected results line. + let popup_width = 80u16.min(area.width); + let popup_height = 6u16.min(area.height); + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + let block = Block::bordered() + .title("Result detail") + .style(styles::popup_block(&theme)) + .border_style(styles::popup_border(&theme)); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + // Resolve selected line index into the flattened results. + let selected_idx = self.state.results_detail_selected_line.unwrap_or(0); + let mut flat: Vec = Vec::new(); + for entry in &self.state.lines { + for l in entry.lines() { + flat.push(l.to_string()); + } + } + let content = flat + .get(selected_idx) + .cloned() + .unwrap_or_else(|| "".to_string()); + + // Render content (trim or pad to width). + let display = if content.len() > popup_width as usize { + let mut s = content.chars().take(popup_width as usize - 3).collect::(); + s.push_str("..."); + s + } else { + content + }; + + Line::from(display) + .style(Style::default().fg(Color::White)) + .render( + Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }, + buf, + ); + + // Hint line at bottom + Line::from("Enter/Space/Esc to close") + .style(styles::default_gray(&theme)) + .render( + Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }, + buf, + ); + } } } @@ -476,6 +545,8 @@ mod ui_tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { diff --git a/src/ui/results.rs b/src/ui/results.rs index a40132c..8a7c830 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -1,5 +1,6 @@ use crate::app::App; use ratatui::prelude::{Buffer, Rect}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; @@ -194,6 +195,8 @@ mod tests { save_filter_name: String::new(), load_filter_popup_open: false, load_filter_selected: 0, + results_detail_popup_open: false, + results_detail_selected_line: None, }; App { @@ -260,6 +263,39 @@ mod tests { ); } + #[test] + fn selected_line_uses_results_selected_style() { + let mut app = make_results_app(vec![ + "2025-12-22T21:25:28.694+00:00 first line", + "2025-12-22T21:25:29.694+00:00 second line", + ]); + + // Select the second line + app.state.results_selected = 1; + + let area = Rect::new(0, 0, 80, 4); + let mut buf = Buffer::empty(area); + + app.render_results(area, &mut buf); + + // The second visual line (y = 1) should have the results_selected background. + let y_selected = area.y + 1; + let mut has_selected_bg = false; + for x in area.x..area.x + area.width { + if let Some(cell) = buf.cell((x, y_selected)) { + if cell.style().bg == app.state.theme.results_selected.bg { + has_selected_bg = true; + break; + } + } + } + + assert!( + has_selected_bg, + "expected selected line to use results_selected background style" + ); + } + #[test] fn tabs_are_expanded_without_merging_words() { let app = make_results_app(vec![ From b23f75228a4c5b7b31dad03eba993bb1d27701e5 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 16:42:45 +0000 Subject: [PATCH 3/9] Add support for scrolling in results detail popup. --- src/app/clipboard.rs | 36 +++++++++++++++++++ src/app/filters.rs | 1 + src/app/keymap.rs | 83 ++++++++++++++++++++++++++++++++++++++++++-- src/app/mod.rs | 1 + src/app/state.rs | 3 +- src/main.rs | 1 + src/ui/mod.rs | 41 +++++++++++----------- src/ui/results.rs | 1 + 8 files changed, 144 insertions(+), 23 deletions(-) diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index f6125c5..7e0b5bd 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -23,6 +23,41 @@ impl App { } } } + + /// Copy only the currently selected result line to the clipboard. + /// + /// This uses the same flattening logic as the results renderer and popup: + /// - Each entry in `state.lines` may contain embedded newlines. + /// - We split entries into individual lines and build a flat list. + /// - The index comes from `results_detail_selected_line` when present, + /// otherwise it falls back to `results_selected`. + pub fn copy_selected_result_to_clipboard(&mut self) { + // Flatten entries into individual lines. + let mut flat: Vec = Vec::new(); + for entry in &self.state.lines { + for l in entry.lines() { + flat.push(l.to_string()); + } + } + + // Prefer the explicit detail selection (popup), otherwise use the + // currently selected results row. + let idx = match self.state.results_detail_selected_line { + Some(i) => i, + None => self.state.results_selected, + }; + + let Some(line) = flat.get(idx) else { + return; + }; + + if let Ok(mut clipboard) = Clipboard::new() { + if clipboard.set_text(line.clone()).is_ok() { + self.state.status_message = Some("Copied selected result line".to_string()); + self.state.status_set_at = Some(Instant::now()); + } + } + } } #[cfg(test)] @@ -81,6 +116,7 @@ mod tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { diff --git a/src/app/filters.rs b/src/app/filters.rs index a79a576..ff79af3 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -248,6 +248,7 @@ mod tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { diff --git a/src/app/keymap.rs b/src/app/keymap.rs index 35905b2..82a27ad 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -27,6 +27,26 @@ impl App { // Close the results detail popup on Esc, Enter, or Space. self.state.results_detail_popup_open = false; self.state.results_detail_selected_line = None; + self.state.results_detail_scroll = 0; + } + KeyCode::Up => { + // Scroll up within the detail popup (saturating at 0). + self.state.results_detail_scroll = + self.state.results_detail_scroll.saturating_sub(1); + } + KeyCode::Down => { + // Scroll down within the detail popup; we let the Paragraph + // handle clamping, so this can grow without precomputing max. + self.state.results_detail_scroll = + self.state.results_detail_scroll.saturating_add(1); + } + // While in the results detail popup, allow 'y' to copy only the + // selected result line to the clipboard, then close the popup. + KeyCode::Char('y') => { + self.copy_selected_result_to_clipboard(); + self.state.results_detail_popup_open = false; + self.state.results_detail_selected_line = None; + self.state.results_detail_scroll = 0; } _ => {} } @@ -134,6 +154,7 @@ impl App { // Open results detail popup for the currently selected results line. self.state.results_detail_popup_open = true; self.state.results_detail_selected_line = Some(self.state.results_selected); + self.state.results_detail_scroll = 0; } else if self.state.focus == Focus::Filter && self.state.filter_field == FilterField::Search && !self.state.editing @@ -164,10 +185,15 @@ impl App { KeyCode::Char(' ') if !self.state.editing && self.state.focus == Focus::Results => { self.state.results_detail_popup_open = true; self.state.results_detail_selected_line = Some(self.state.results_selected); + self.state.results_detail_scroll = 0; } - // Copy results to clipboard (Results pane, not editing) - KeyCode::Char('y') if !self.state.editing && self.state.focus == Focus::Results => { + // Copy results to clipboard (Results pane, not editing, and no popup) + KeyCode::Char('y') + if !self.state.editing + && self.state.focus == Focus::Results + && !self.state.results_detail_popup_open => + { self.copy_results_to_clipboard(); } @@ -307,6 +333,7 @@ mod tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { @@ -362,6 +389,40 @@ mod tests { assert_eq!(app.state.theme_name, "dark"); } + #[test] + fn results_detail_popup_scrolls_with_up_down() { + let mut app = app_with_filter_query(""); + app.state.focus = Focus::Results; + // Very long line that will wrap across multiple rows in the popup. + app.state.lines = vec![ + "This is a very long log line that should wrap across multiple lines in the result detail popup when rendered with a Paragraph widget." + .to_string(), + ]; + app.state.results_selected = 0; + + // Open popup + app.handle_key_event(key(KeyCode::Enter)).unwrap(); + assert!(app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_scroll, 0); + + // Scroll down a few steps + app.handle_key_event(key(KeyCode::Down)).unwrap(); + app.handle_key_event(key(KeyCode::Down)).unwrap(); + assert!( + app.state.results_detail_scroll >= 2, + "expected scroll to increase when pressing Down" + ); + + // Scroll up; should not go below 0 + app.handle_key_event(key(KeyCode::Up)).unwrap(); + app.handle_key_event(key(KeyCode::Up)).unwrap(); + app.handle_key_event(key(KeyCode::Up)).unwrap(); + assert_eq!( + app.state.results_detail_scroll, 0, + "expected scroll to saturate at 0 when scrolling up" + ); + } + #[test] fn enter_opens_results_detail_popup_for_selected_line() { let mut app = app_with_filter_query(""); @@ -428,7 +489,25 @@ mod tests { // Close with Esc app.handle_key_event(key(KeyCode::Esc)).unwrap(); + assert_eq!(app.state.results_detail_popup_open, false); + assert_eq!(app.state.results_detail_selected_line, None); + } + + #[test] + fn yank_in_results_detail_popup_copies_and_closes() { + let mut app = app_with_filter_query(""); + app.state.focus = Focus::Results; + app.state.lines = vec!["line0".to_string(), "line1".to_string()]; + app.state.results_selected = 1; + + // Open popup + app.handle_key_event(key(KeyCode::Enter)).unwrap(); + assert!(app.state.results_detail_popup_open); + + // Press 'y' inside popup: should copy (reusing existing logic) and close + app.handle_key_event(key(KeyCode::Char('y'))).unwrap(); assert!(!app.state.results_detail_popup_open); assert_eq!(app.state.results_detail_selected_line, None); + assert_eq!(app.state.results_detail_scroll, 0); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 2d67a4f..484ca5c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -527,6 +527,7 @@ mod tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { diff --git a/src/app/state.rs b/src/app/state.rs index 1833963..565d2c3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -46,7 +46,8 @@ pub struct AppState { pub load_filter_popup_open: bool, pub load_filter_selected: usize, - // Results detail popup: whether it's open and which line is shown + // Results detail popup: whether it's open and which line is shown, and vertical scroll. pub results_detail_popup_open: bool, pub results_detail_selected_line: Option, + pub results_detail_scroll: usize, } diff --git a/src/main.rs b/src/main.rs index f762067..6cd35ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,7 @@ fn main() -> Result<(), Box> { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; let mut app = App { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8f8d2f5..92f2cb4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -421,7 +421,8 @@ impl Widget for &App { if self.state.results_detail_popup_open { // Results detail popup showing the currently selected results line. let popup_width = 80u16.min(area.width); - let popup_height = 6u16.min(area.height); + // Allow a taller popup so we can show more wrapped content. + let popup_height = 12u16.min(area.height); let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; @@ -452,26 +453,25 @@ impl Widget for &App { .cloned() .unwrap_or_else(|| "".to_string()); - // Render content (trim or pad to width). - let display = if content.len() > popup_width as usize { - let mut s = content.chars().take(popup_width as usize - 3).collect::(); - s.push_str("..."); - s - } else { - content - }; + // Use a Paragraph with word wrapping and a simple vertical scroll offset. + // We leave 1 row at the bottom for the hint line. + let available_height = inner.height.saturating_sub(1); + let scroll = self.state.results_detail_scroll as u16; - Line::from(display) - .style(Style::default().fg(Color::White)) - .render( - Rect { - x: inner.x, - y: inner.y, - width: inner.width, - height: 1, - }, - buf, - ); + let paragraph = ratatui::widgets::Paragraph::new(content) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((scroll, 0)) + .style(Style::default().fg(Color::White)); + + paragraph.render( + Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: available_height, + }, + buf, + ); // Hint line at bottom Line::from("Enter/Space/Esc to close") @@ -547,6 +547,7 @@ mod ui_tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { diff --git a/src/ui/results.rs b/src/ui/results.rs index 8a7c830..f4fcffb 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -197,6 +197,7 @@ mod tests { load_filter_selected: 0, results_detail_popup_open: false, results_detail_selected_line: None, + results_detail_scroll: 0, }; App { From b3b74768c0d882cc393bf1093d438ebfdf98a499 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 16:53:39 +0000 Subject: [PATCH 4/9] Fix popup background opacity and add hint line in UI popup. --- src/ui/mod.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 92f2cb4..22db2bf 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -438,6 +438,19 @@ impl Widget for &App { .style(styles::popup_block(&theme)) .border_style(styles::popup_border(&theme)); let inner = block.inner(popup_area); + + // Fully clear the inner area with the popup background so it is opaque. + let popup_bg = theme.popup_block.bg.unwrap_or(Color::Rgb(30, 30, 30)); + for y in inner.y..inner.y + inner.height { + for x in inner.x..inner.x + inner.width { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_bg(popup_bg); + // Also clear any previous glyphs to a space for consistency. + cell.set_symbol(" "); + } + } + } + block.render(popup_area, buf); // Resolve selected line index into the flattened results. @@ -458,10 +471,13 @@ impl Widget for &App { let available_height = inner.height.saturating_sub(1); let scroll = self.state.results_detail_scroll as u16; + // Match the popup_block foreground/background for paragraph text. + let popup_fg = theme.popup_block.fg.unwrap_or(Color::White); + let paragraph = ratatui::widgets::Paragraph::new(content) .wrap(ratatui::widgets::Wrap { trim: false }) .scroll((scroll, 0)) - .style(Style::default().fg(Color::White)); + .style(Style::default().fg(popup_fg).bg(popup_bg)); paragraph.render( Rect { @@ -473,9 +489,10 @@ impl Widget for &App { buf, ); - // Hint line at bottom - Line::from("Enter/Space/Esc to close") - .style(styles::default_gray(&theme)) + // Hint line at bottom, with same background as popup_block so the + // popup interior remains opaque. + Line::from("Enter/Space/Esc to close the popup | y to copy selected line") + .style(styles::default_gray(&theme).bg(popup_bg)) .render( Rect { x: inner.x, From eae2a2bb8f9c67b3afcd11650e09213326e306df Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 17:52:20 +0000 Subject: [PATCH 5/9] Bump version to 0.3.3 and add result selection & detail view feature. --- Cargo.toml | 2 +- README.md | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index daa7564..af55168 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lumberjack" -version = "0.3.2" +version = "0.3.3" edition = "2024" [dependencies] diff --git a/README.md b/README.md index ec4c0ce..6a0cfff 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ Built in **Rust**, powered by **ratatui**, **crossterm**, and the **AWS SDK for - Keeps underlying log lines intact for copying - Designed to play nicely with large, structured payloads - 📜 Scrollable results with a real scrollbar (no infinite-scroll roulette) +- 🔎 Result selection & popup detail view + - Move a highlight through Results with `↑` / `↓` + - Press `Enter` or `Space` on a selected line to open a **Result detail** popup + - Popup shows the full line with word wrapping and vertical scrolling + - Press `y` in the popup to copy just the selected line to the clipboard - ⌨️ Keyboard-driven UI - `/` fuzzy-search groups - `1/2/3/4` for time presets @@ -78,13 +83,15 @@ cargo run -- --profile= --region= - `Tab` – Switch between Groups / Filter / Results - `/` – Fuzzy-search log groups (when Groups pane is focused) -- `↑` / `↓` – Move selection / scroll -- `Enter` – Edit filter field / run search +- `↑` / `↓` – Move selection / scroll (and move the highlighted result row when Results pane is focused) +- `Enter` – Edit filter field / run search; in Results pane, opens Result detail popup for the highlighted line - `1` / `2` / `3` / `4` – Quick time presets for **Start** (sets Start to `-5m` / `-15m` / `-1h` / `-24h`, and clears End to “now”) - `s` – Save current filter (opens name popup; persists to `~/.config/lumberjack/filters.json`) - `F` – Load saved filter (opens popup with saved filter names) - `t` – Toggle tail/stream mode for results - `T` – Cycle color themes (Dark → Light → Green CRT) - `Esc` – Cancel editing, group search, or close popups -- `y` – Copy all Results to clipboard (when Results pane is focused) +- `y` – Copy all Results to clipboard (when Results pane is focused and no popup is open) +- `Space` – In Results pane, open Result detail popup for the highlighted line +- `Y` – In Result detail popup, copy only the selected line to clipboard and close the popup - `q` – Quit (except while editing or in group search) From 56fb9d3b342ed87f3d243f7076834a375e11e1ee Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 17:58:06 +0000 Subject: [PATCH 6/9] Update dependencies and refactor code structure. --- Cargo.lock | 2 +- src/app/clipboard.rs | 7 ------- src/app/filters.rs | 2 -- src/app/state.rs | 1 - src/ui/mod.rs | 9 --------- src/ui/results.rs | 1 - 6 files changed, 1 insertion(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afb996d..2a1a776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,7 +1250,7 @@ dependencies = [ [[package]] name = "lumberjack" -version = "0.3.2" +version = "0.3.3" dependencies = [ "arboard", "aws-config", diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index 7e0b5bd..cdedcb1 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -24,13 +24,6 @@ impl App { } } - /// Copy only the currently selected result line to the clipboard. - /// - /// This uses the same flattening logic as the results renderer and popup: - /// - Each entry in `state.lines` may contain embedded newlines. - /// - We split entries into individual lines and build a flat list. - /// - The index comes from `results_detail_selected_line` when present, - /// otherwise it falls back to `results_selected`. pub fn copy_selected_result_to_clipboard(&mut self) { // Flatten entries into individual lines. let mut flat: Vec = Vec::new(); diff --git a/src/app/filters.rs b/src/app/filters.rs index ff79af3..a2a0312 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -146,8 +146,6 @@ impl App { } fn filters_path() -> Result { - // In tests, write filters to a separate location so we don't overwrite - // the user's real filters. if cfg!(test) { let home = std::env::var("HOME").map_err(|e| format!("HOME not set: {e}"))?; let mut path = PathBuf::from(home); diff --git a/src/app/state.rs b/src/app/state.rs index 565d2c3..bfcc17c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -46,7 +46,6 @@ pub struct AppState { pub load_filter_popup_open: bool, pub load_filter_selected: usize, - // Results detail popup: whether it's open and which line is shown, and vertical scroll. pub results_detail_popup_open: bool, pub results_detail_selected_line: Option, pub results_detail_scroll: usize, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 22db2bf..8278dc4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -209,10 +209,6 @@ impl Widget for &App { // ---- fake blinking cursor inside the active filter field ---- if self.state.focus == Focus::Filter && self.state.editing && self.state.cursor_on { - // Which row is the active field on? - // - // NOTE: The presets hint is non-interactive; only the text fields and - // the Search button participate in cursor positioning. let field_row = match self.state.filter_field { FilterField::Start => 0, FilterField::End => 1, @@ -466,12 +462,9 @@ impl Widget for &App { .cloned() .unwrap_or_else(|| "".to_string()); - // Use a Paragraph with word wrapping and a simple vertical scroll offset. - // We leave 1 row at the bottom for the hint line. let available_height = inner.height.saturating_sub(1); let scroll = self.state.results_detail_scroll as u16; - // Match the popup_block foreground/background for paragraph text. let popup_fg = theme.popup_block.fg.unwrap_or(Color::White); let paragraph = ratatui::widgets::Paragraph::new(content) @@ -489,8 +482,6 @@ impl Widget for &App { buf, ); - // Hint line at bottom, with same background as popup_block so the - // popup interior remains opaque. Line::from("Enter/Space/Esc to close the popup | y to copy selected line") .style(styles::default_gray(&theme).bg(popup_bg)) .render( diff --git a/src/ui/results.rs b/src/ui/results.rs index f4fcffb..7d0be2f 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -1,6 +1,5 @@ use crate::app::App; use ratatui::prelude::{Buffer, Rect}; -use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; From 2664ae9537f50b60fbe5ba4bbb319ec4286f3a38 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 18:58:08 +0000 Subject: [PATCH 7/9] Add SQL query formatting and copying functionality to popup in TUI. --- Cargo.lock | 26 +++++++++++ Cargo.toml | 1 + README.md | 5 ++- src/app/clipboard.rs | 102 ++++++++++++++++++++++++++++++++++++++----- src/app/keymap.rs | 14 +++--- src/ui/mod.rs | 79 ++++++++++++++++++++++++++++++++- 6 files changed, 204 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a1a776..64a789b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "sqlformat", "thiserror", "tokio", ] @@ -1782,6 +1783,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sqlformat" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" +dependencies = [ + "unicode_categories", + "winnow", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2059,6 +2070,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" @@ -2426,6 +2443,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index af55168..2d39017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ tokio = {version="1.48.0", default-features = false, features = ["rt-multi-threa arboard = { version = "3.6.1", default-features = false } serde = {version = "1.0.228", default-features = false, features = ["derive"]} thiserror = "2.0.17" +sqlformat = "0.5.0" [profile.release] lto = true diff --git a/README.md b/README.md index 6a0cfff..3fc52ee 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Built in **Rust**, powered by **ratatui**, **crossterm**, and the **AWS SDK for - Move a highlight through Results with `↑` / `↓` - Press `Enter` or `Space` on a selected line to open a **Result detail** popup - Popup shows the full line with word wrapping and vertical scrolling - - Press `y` in the popup to copy just the selected line to the clipboard + - When a JSON `"sql"` field is present, the popup shows the SQL query as a nicely formatted, multi-line block + - Press `y` in the popup to copy the formatted SQL (or the selected line when not SQL) to the clipboard - ⌨️ Keyboard-driven UI - `/` fuzzy-search groups - `1/2/3/4` for time presets @@ -93,5 +94,5 @@ cargo run -- --profile= --region= - `Esc` – Cancel editing, group search, or close popups - `y` – Copy all Results to clipboard (when Results pane is focused and no popup is open) - `Space` – In Results pane, open Result detail popup for the highlighted line -- `Y` – In Result detail popup, copy only the selected line to clipboard and close the popup +- `y` – In Result detail popup, copy the formatted SQL when a JSON `"sql"` field is present (otherwise copy the selected line); popup remains open - `q` – Quit (except while editing or in group search) diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index cdedcb1..ee77afb 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -1,5 +1,6 @@ use crate::app::App; use arboard::Clipboard; +use sqlformat::{self, Dialect, FormatOptions, Indent, QueryParams}; use std::time::Instant; impl App { @@ -25,7 +26,37 @@ impl App { } pub fn copy_selected_result_to_clipboard(&mut self) { - // Flatten entries into individual lines. + // If the results detail popup is open, try to detect and pretty-print + // any JSON sql field and copy the formatted SQL only. + if self.state.results_detail_popup_open { + let line = self.flatten_selected_line(); + if let Some(formatted) = format_sql_for_clipboard(&line) { + if let Ok(mut clipboard) = Clipboard::new() { + if clipboard.set_text(formatted).is_ok() { + self.state.status_message = + Some("Copied formatted SQL from popup".to_string()); + self.state.status_set_at = Some(Instant::now()); + } + } + return; + } + } + + // Otherwise, fall back to copying the raw selected line. + let line = self.flatten_selected_line(); + if line.is_empty() { + return; + } + + if let Ok(mut clipboard) = Clipboard::new() { + if clipboard.set_text(line).is_ok() { + self.state.status_message = Some("Copied selected result line".to_string()); + self.state.status_set_at = Some(Instant::now()); + } + } + } + + fn flatten_selected_line(&self) -> String { let mut flat: Vec = Vec::new(); for entry in &self.state.lines { for l in entry.lines() { @@ -33,24 +64,75 @@ impl App { } } - // Prefer the explicit detail selection (popup), otherwise use the - // currently selected results row. let idx = match self.state.results_detail_selected_line { Some(i) => i, None => self.state.results_selected, }; - let Some(line) = flat.get(idx) else { - return; - }; + flat.get(idx).cloned().unwrap_or_default() + } +} - if let Ok(mut clipboard) = Clipboard::new() { - if clipboard.set_text(line.clone()).is_ok() { - self.state.status_message = Some("Copied selected result line".to_string()); - self.state.status_set_at = Some(Instant::now()); +fn format_sql_for_clipboard(line: &str) -> Option { + // Look for a JSON `"sql": ""` field and pretty-print that value. + let sql_key_pos = line.find("\"sql\"")?; + let after_key = &line[sql_key_pos..]; + let colon_rel = after_key.find(':')?; + let colon_pos = sql_key_pos + colon_rel; + + // Find starting quote of the SQL string + let chars: Vec = line.chars().collect(); + let mut start = None; + for i in colon_pos + 1..chars.len() { + if chars[i].is_whitespace() { + continue; + } + if chars[i] == '"' { + start = Some(i + 1); + } + break; + } + let start = start?; + + // Find the closing quote (next unescaped `"`) + let mut end = None; + let mut i = start; + while i < chars.len() { + if chars[i] == '"' { + let mut backslashes = 0; + let mut j = i; + while j > 0 && chars[j - 1] == '\\' { + backslashes += 1; + j -= 1; + } + if backslashes % 2 == 0 { + end = Some(i); + break; } } + i += 1; } + let end = end?; + + let sql_raw: String = chars[start..end].iter().collect(); + + // Use sqlformat to pretty-print the SQL value. + let mut opts = FormatOptions::default(); + opts.indent = Indent::Spaces(2); + opts.uppercase = Some(true); + opts.lines_between_queries = 1; + opts.ignore_case_convert = None; + opts.inline = false; + opts.max_inline_block = 50; + opts.max_inline_arguments = None; + opts.max_inline_top_level = None; + opts.joins_as_top_level = false; + opts.dialect = Dialect::Generic; + + let formatted_sql = + sqlformat::format(&sql_raw, &QueryParams::None, &opts); + + Some(formatted_sql) } #[cfg(test)] diff --git a/src/app/keymap.rs b/src/app/keymap.rs index 82a27ad..2965e91 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -41,12 +41,9 @@ impl App { self.state.results_detail_scroll.saturating_add(1); } // While in the results detail popup, allow 'y' to copy only the - // selected result line to the clipboard, then close the popup. + // selected result line to the clipboard, but keep the popup open. KeyCode::Char('y') => { self.copy_selected_result_to_clipboard(); - self.state.results_detail_popup_open = false; - self.state.results_detail_selected_line = None; - self.state.results_detail_scroll = 0; } _ => {} } @@ -494,7 +491,7 @@ mod tests { } #[test] - fn yank_in_results_detail_popup_copies_and_closes() { + fn yank_in_results_detail_popup_copies_and_leaves_open() { let mut app = app_with_filter_query(""); app.state.focus = Focus::Results; app.state.lines = vec!["line0".to_string(), "line1".to_string()]; @@ -504,10 +501,9 @@ mod tests { app.handle_key_event(key(KeyCode::Enter)).unwrap(); assert!(app.state.results_detail_popup_open); - // Press 'y' inside popup: should copy (reusing existing logic) and close + // Press 'y' inside popup: should copy the selected line and keep popup open app.handle_key_event(key(KeyCode::Char('y'))).unwrap(); - assert!(!app.state.results_detail_popup_open); - assert_eq!(app.state.results_detail_selected_line, None); - assert_eq!(app.state.results_detail_scroll, 0); + assert!(app.state.results_detail_popup_open); + assert_eq!(app.state.results_detail_selected_line, Some(1)); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8278dc4..394c2c4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,7 @@ use ratatui::prelude::Rect; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::Line; use ratatui::widgets::{Block, Widget}; +use sqlformat::{self, Dialect, FormatOptions, Indent, QueryParams}; use crate::app::{App, FilterField, Focus}; @@ -462,12 +463,16 @@ impl Widget for &App { .cloned() .unwrap_or_else(|| "".to_string()); + // For the popup, if we detect a JSON sql field, render only the + // pretty-printed SQL query. Otherwise, show the original content. + let display_content = format_sql_for_popup(&content); + let available_height = inner.height.saturating_sub(1); let scroll = self.state.results_detail_scroll as u16; let popup_fg = theme.popup_block.fg.unwrap_or(Color::White); - let paragraph = ratatui::widgets::Paragraph::new(content) + let paragraph = ratatui::widgets::Paragraph::new(display_content) .wrap(ratatui::widgets::Wrap { trim: false }) .scroll((scroll, 0)) .style(Style::default().fg(popup_fg).bg(popup_bg)); @@ -482,7 +487,7 @@ impl Widget for &App { buf, ); - Line::from("Enter/Space/Esc to close the popup | y to copy selected line") + Line::from("Enter/Space/Esc to close the popup | Y copy selected line") .style(styles::default_gray(&theme).bg(popup_bg)) .render( Rect { @@ -497,6 +502,76 @@ impl Widget for &App { } } +fn format_sql_for_popup(line: &str) -> String { + if let Some(formatted) = format_json_sql_field(line) { + // When we have a JSON sql field, show only the pretty-printed query. + return formatted; + } + line.to_string() +} + +fn format_json_sql_field(line: &str) -> Option { + // Look for a JSON `"sql": ""` field and pretty-print that value. + let sql_key_pos = line.find("\"sql\"")?; + let after_key = &line[sql_key_pos..]; + let colon_rel = after_key.find(':')?; + let colon_pos = sql_key_pos + colon_rel; + + // Find starting quote of the SQL string + let chars: Vec = line.chars().collect(); + let mut start = None; + for i in colon_pos + 1..chars.len() { + if chars[i].is_whitespace() { + continue; + } + if chars[i] == '"' { + start = Some(i + 1); + } + break; + } + let start = start?; + + // Find the closing quote (next unescaped `"`) + let mut end = None; + let mut i = start; + while i < chars.len() { + if chars[i] == '"' { + let mut backslashes = 0; + let mut j = i; + while j > 0 && chars[j - 1] == '\\' { + backslashes += 1; + j -= 1; + } + if backslashes % 2 == 0 { + end = Some(i); + break; + } + } + i += 1; + } + let end = end?; + + let sql_raw: String = chars[start..end].iter().collect(); + + // Use sqlformat to pretty-print the SQL value. + let mut opts = FormatOptions::default(); + opts.indent = Indent::Spaces(2); + opts.uppercase = Some(true); + opts.lines_between_queries = 1; + opts.ignore_case_convert = None; + opts.inline = false; + opts.max_inline_block = 50; + opts.max_inline_arguments = None; + opts.max_inline_top_level = None; + opts.joins_as_top_level = false; + opts.dialect = Dialect::Generic; + + let formatted_sql = sqlformat::format(&sql_raw, &QueryParams::None, &opts); + + // For popup display, just show the formatted SQL itself. + Some(formatted_sql) +} + #[cfg(test)] mod ui_tests { use super::*; From c684b12abb354cc7160776c536cae25f7a3d60e6 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 19:23:21 +0000 Subject: [PATCH 8/9] Reset results scroll offset and selection in App struct method. --- Cargo.toml | 2 +- src/app/mod.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2d39017..d361bdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ tokio = {version="1.48.0", default-features = false, features = ["rt-multi-threa arboard = { version = "3.6.1", default-features = false } serde = {version = "1.0.228", default-features = false, features = ["derive"]} thiserror = "2.0.17" -sqlformat = "0.5.0" +sqlformat = { version = "0.5.0", default-features = false } [profile.release] lto = true diff --git a/src/app/mod.rs b/src/app/mod.rs index 484ca5c..fb6404a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -290,7 +290,8 @@ impl App { self.state.last_dots = Instant::now(); self.state.focus = Focus::Results; // lose focus from form self.state.editing = false; - self.state.lines.clear(); // optional + self.state.lines.clear(); + self.reset_results_scroll(); self.state.results_scroll = 0; self.tail_stop.store(false, Ordering::Relaxed); @@ -471,6 +472,11 @@ impl App { } } } + + fn reset_results_scroll(&mut self) { + self.state.results_scroll = 0; + self.state.results_selected = 0; + } } #[cfg(test)] @@ -539,6 +545,67 @@ mod tests { } } + fn app_with_results_state(lines: Vec<&str>) -> App { + let (tx, rx) = std::sync::mpsc::channel(); + + let state = AppState { + app_title: "Test".to_string(), + theme: Theme::default_dark(), + theme_name: "dark".to_string(), + lines: lines.into_iter().map(|s| s.to_string()).collect(), + filter_cursor_pos: 0, + + all_groups: Vec::new(), + groups: Vec::new(), + selected_group: 0, + groups_scroll: 0, + + profile: "test-profile".to_string(), + region: "eu-west-1".to_string(), + focus: Focus::Results, + + filter_start: String::new(), + filter_end: String::new(), + filter_query: String::new(), + filter_field: FilterField::Query, + editing: false, + cursor_on: true, + last_blink: Instant::now(), + + group_search_active: false, + group_search_input: String::new(), + + searching: false, + dots: 0, + last_dots: Instant::now(), + results_scroll: 0, + results_selected: 0, + + tail_mode: false, + + status_message: None, + status_set_at: None, + + saved_filters: Vec::new(), + save_filter_popup_open: false, + save_filter_name: String::new(), + load_filter_popup_open: false, + load_filter_selected: 0, + + results_detail_popup_open: false, + results_detail_selected_line: None, + results_detail_scroll: 0, + }; + + App { + state, + exit: false, + search_tx: tx, + search_rx: rx, + tail_stop: Arc::new(AtomicBool::new(false)), + } + } + // --- fuzzy_match tests --- #[test] @@ -661,4 +728,23 @@ mod tests { "expected status_set_at to be cleared after timeout" ); } + + #[test] + fn reset_results_scroll_resets_offset_and_selection() { + let mut app = app_with_results_state(vec![ + "line 1", + "line 2", + "line 3", + "line 4", + ]); + + // Simulate having scrolled and selected somewhere in the middle + app.state.results_scroll = 2; + app.state.results_selected = 3; + + app.reset_results_scroll(); + + assert_eq!(app.state.results_scroll, 0, "results_scroll should be reset to 0"); + assert_eq!(app.state.results_selected, 0, "results_selected should be reset to 0"); + } } From 9f6485744d5063573766ad57e60158ab3d9b20da Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 8 Jan 2026 19:27:30 +0000 Subject: [PATCH 9/9] Update popup instructions to use lowercase 'y' for consistency. --- src/ui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 394c2c4..d29333b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -487,7 +487,7 @@ impl Widget for &App { buf, ); - Line::from("Enter/Space/Esc to close the popup | Y copy selected line") + Line::from("Enter/Space/Esc to close the popup | y copy") .style(styles::default_gray(&theme).bg(popup_bg)) .render( Rect {