Skip to content
28 changes: 27 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lumberjack"
version = "0.3.2"
version = "0.3.3"
edition = "2024"

[dependencies]
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,13 +84,15 @@ cargo run -- --profile=<aws-profile> --region=<aws-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)
114 changes: 114 additions & 0 deletions src/app/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::app::App;
use arboard::Clipboard;
use sqlformat::{self, Dialect, FormatOptions, Indent, QueryParams};
use std::time::Instant;

impl App {
Expand All @@ -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<String> = 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<String> {
// Look for a JSON `"sql": "<value>"` 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<char> = 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)]
Expand Down Expand Up @@ -68,6 +178,7 @@ mod tests {
dots: 0,
last_dots: StdInstant::now(),
results_scroll: 0,
results_selected: 0,
tail_mode: false,

status_message: None,
Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/app/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,6 @@ impl App {
}

fn filters_path() -> Result<PathBuf, String> {
// 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);
Expand Down Expand Up @@ -234,6 +232,7 @@ mod tests {
dots: 0,
last_dots: StdInstant::now(),
results_scroll: 0,
results_selected: 0,

tail_mode: false,

Expand All @@ -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 {
Expand Down
Loading