diff --git a/Cargo.lock b/Cargo.lock index afb996d..64a789b 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", @@ -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 daa7564..d361bdd 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] @@ -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 = { version = "0.5.0", default-features = false } [profile.release] lto = true diff --git a/README.md b/README.md index ec4c0ce..3fc52ee 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ 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 + - 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 @@ -78,13 +84,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 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 357e103..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 { @@ -23,6 +24,115 @@ impl App { } } } + + pub fn copy_selected_result_to_clipboard(&mut self) { + // 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() { + flat.push(l.to_string()); + } + } + + let idx = match self.state.results_detail_selected_line { + Some(i) => i, + None => self.state.results_selected, + }; + + flat.get(idx).cloned().unwrap_or_default() + } +} + +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)] @@ -68,6 +178,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, @@ -78,6 +189,9 @@ 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, + results_detail_scroll: 0, }; App { diff --git a/src/app/filters.rs b/src/app/filters.rs index db5c9c4..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); @@ -234,6 +232,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, @@ -245,6 +244,9 @@ 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, + results_detail_scroll: 0, }; App { diff --git a/src/app/keymap.rs b/src/app/keymap.rs index afeabe8..2965e91 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -21,6 +21,34 @@ 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; + 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, but keep the popup open. + KeyCode::Char('y') => { + self.copy_selected_result_to_clipboard(); + } + _ => {} + } + return Ok(()); + } match key_event.code { // q should NOT quit while editing or while group search is active @@ -117,9 +145,14 @@ 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); + self.state.results_detail_scroll = 0; + } else if self.state.focus == Focus::Filter && self.state.filter_field == FilterField::Search && !self.state.editing { @@ -145,8 +178,19 @@ impl App { Focus::Results => self.results_down(), }, - // Copy results to clipboard (Results pane, not editing) - KeyCode::Char('y') if !self.state.editing && self.state.focus == Focus::Results => { + // 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); + self.state.results_detail_scroll = 0; + } + + // 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(); } @@ -272,6 +316,7 @@ mod tests { dots: 0, last_dots: StdInstant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, @@ -283,6 +328,9 @@ 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, + results_detail_scroll: 0, }; App { @@ -337,4 +385,125 @@ mod tests { app.handle_key_event(key(KeyCode::Char('T'))).unwrap(); 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(""); + // 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_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_leaves_open() { + 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 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, Some(1)); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 853dd73..fb6404a 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; } } @@ -266,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); @@ -447,6 +472,11 @@ impl App { } } } + + fn reset_results_scroll(&mut self) { + self.state.results_scroll = 0; + self.state.results_selected = 0; + } } #[cfg(test)] @@ -489,6 +519,7 @@ mod tests { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, @@ -500,6 +531,70 @@ 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, + results_detail_scroll: 0, + }; + + App { + state, + exit: false, + search_tx: tx, + search_rx: rx, + tail_stop: Arc::new(AtomicBool::new(false)), + } + } + + 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 { @@ -633,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"); + } } diff --git a/src/app/state.rs b/src/app/state.rs index a88716d..bfcc17c 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, @@ -45,4 +45,8 @@ pub struct AppState { pub save_filter_name: String, pub load_filter_popup_open: bool, pub load_filter_selected: usize, + + 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 2af0915..6cd35ed 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, @@ -78,6 +79,9 @@ 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, + results_detail_scroll: 0, }; let mut app = App { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab4a117..d29333b 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}; @@ -209,10 +210,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, @@ -417,7 +414,162 @@ 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); + // 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; + + 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); + + // 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. + 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()); + + // 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(display_content) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((scroll, 0)) + .style(Style::default().fg(popup_fg).bg(popup_bg)); + + paragraph.render( + Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: available_height, + }, + buf, + ); + + Line::from("Enter/Space/Esc to close the popup | y copy") + .style(styles::default_gray(&theme).bg(popup_bg)) + .render( + Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }, + buf, + ); + } + } +} + +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)] @@ -465,6 +617,7 @@ mod ui_tests { dots: 0, last_dots: Instant::now(), results_scroll: 0, + results_selected: 0, tail_mode: false, status_message: None, @@ -475,6 +628,9 @@ 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, + results_detail_scroll: 0, }; App { diff --git a/src/ui/results.rs b/src/ui/results.rs index f902f68..7d0be2f 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, @@ -176,6 +194,9 @@ 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, + results_detail_scroll: 0, }; App { @@ -242,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![ 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 } }