feat(tui): add Tab key to toggle JSON pretty-print
Press Tab to switch JSON log lines between raw single-line and indented multi-line format (2-space indent, like jq output). Only JSON Object lines are affected; plain text lines stay unchanged. - Add json_format bool field to App, toggled by Tab - Add format_json_line() standalone function with serde_json::to_string_pretty - Integrate into recompute_wrap_cache with \n-split sub-line wrapping - Center viewport on cursor after cache rebuild to prevent jump on toggle - Add 12 unit tests covering format, toggle, and edge cases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -9,4 +9,4 @@ crossterm.workspace = true
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
log-viewer-core.workspace = true
|
||||
textwrap.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -42,6 +42,24 @@ fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Format a line as pretty-printed JSON if it's a JSON Object.
|
||||
/// Returns the original line unchanged for non-JSON or non-Object content.
|
||||
fn format_json_line(line: &str) -> String {
|
||||
if line.trim().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// Quick pre-check: only try parsing if it starts with '{'
|
||||
if !line.trim_start().starts_with('{') {
|
||||
return line.to_string();
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(line) {
|
||||
Ok(value) if value.is_object() => {
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
_ => line.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub should_quit: bool,
|
||||
|
||||
@@ -66,6 +84,9 @@ pub struct App {
|
||||
|
||||
// gg state machine
|
||||
pub(crate) last_g_press: Option<Instant>,
|
||||
|
||||
// JSON formatting
|
||||
pub(crate) json_format: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -83,6 +104,7 @@ impl App {
|
||||
content_width: 0,
|
||||
content_height: 0,
|
||||
last_g_press: None,
|
||||
json_format: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +119,7 @@ impl App {
|
||||
self.total_visual_rows = 0;
|
||||
self.cached_width = 0; // force wrap cache rebuild on next render
|
||||
self.last_g_press = None; // reset gg state machine
|
||||
self.json_format = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,8 +138,19 @@ impl App {
|
||||
self.visual_heights.reserve(line_count);
|
||||
|
||||
for i in 0..line_count {
|
||||
let line = self.get_line(i).unwrap_or("");
|
||||
let wrapped = wrap_line_chars(line, width);
|
||||
let raw = self.get_line(i).unwrap_or("");
|
||||
let display_text = if self.json_format {
|
||||
format_json_line(raw)
|
||||
} else {
|
||||
raw.to_string()
|
||||
};
|
||||
// Critical: to_string_pretty returns a single string with \n,
|
||||
// but wrap_line_chars doesn't handle \n. Must split on \n first,
|
||||
// then wrap each sub-line individually.
|
||||
let mut wrapped = Vec::new();
|
||||
for sub_line in display_text.split('\n') {
|
||||
wrapped.extend(wrap_line_chars(sub_line, width));
|
||||
}
|
||||
let height = wrapped.len().max(1);
|
||||
self.wrap_cache.push(wrapped);
|
||||
self.visual_heights.push(height);
|
||||
@@ -130,6 +164,13 @@ impl App {
|
||||
.total_visual_rows
|
||||
.saturating_sub(self.content_height as usize);
|
||||
self.v_offset = self.v_offset.min(max_offset);
|
||||
|
||||
// Center on cursor (NOT ensure_cursor_visible — that does minimum
|
||||
// scroll and would jump cursor to viewport edge on format toggle).
|
||||
let cursor_first = self.cursor_to_first_visual_row(self.cursor_line);
|
||||
let half_height = (self.content_height as usize) / 2;
|
||||
self.v_offset = cursor_first.saturating_sub(half_height);
|
||||
self.clamp_v_offset();
|
||||
}
|
||||
|
||||
// ── Scroll methods ──────────────────────────────────────────────
|
||||
@@ -329,6 +370,11 @@ impl App {
|
||||
self.scroll_to_top();
|
||||
self.last_g_press = None;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.json_format = !self.json_format;
|
||||
self.cached_width = 0; // Force wrap cache rebuild
|
||||
self.last_g_press = None;
|
||||
}
|
||||
_ => {
|
||||
self.last_g_press = None;
|
||||
}
|
||||
@@ -581,4 +627,273 @@ mod tests {
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_format_default_off() {
|
||||
let app = App::new();
|
||||
assert!(!app.json_format);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_toggles_json_format() {
|
||||
let path = make_temp_file("test\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||||
app.handle_key(tab);
|
||||
assert!(app.json_format, "Tab should toggle json_format to true");
|
||||
assert_eq!(app.cached_width, 0, "Tab should reset cached_width");
|
||||
|
||||
app.handle_key(tab);
|
||||
assert!(!app.json_format, "Second Tab should toggle back to false");
|
||||
});
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_file_resets_json_format() {
|
||||
let path = make_temp_file("test\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
app.json_format = true;
|
||||
|
||||
let path2 = make_temp_file("test2\n");
|
||||
app.load_file(path2.to_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
!app.json_format,
|
||||
"load_file should reset json_format to false"
|
||||
);
|
||||
cleanup(&path2);
|
||||
});
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_valid() {
|
||||
let result = format_json_line(r#"{"a":1,"b":2}"#);
|
||||
assert!(
|
||||
result.contains('\n'),
|
||||
"formatted JSON should contain newlines"
|
||||
);
|
||||
assert!(
|
||||
result.contains(" "),
|
||||
"formatted JSON should contain 2-space indentation"
|
||||
);
|
||||
assert!(
|
||||
result.contains("\"a\""),
|
||||
"formatted JSON should contain key a"
|
||||
);
|
||||
assert!(
|
||||
result.contains("\"b\""),
|
||||
"formatted JSON should contain key b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_invalid() {
|
||||
let input = "hello world";
|
||||
let result = format_json_line(input);
|
||||
assert_eq!(result, input, "plain text should be returned unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_empty() {
|
||||
let result = format_json_line("");
|
||||
assert_eq!(result, "", "empty input should return empty string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_array() {
|
||||
let input = "[1,2,3]";
|
||||
let result = format_json_line(input);
|
||||
assert_eq!(result, input, "JSON array should be returned unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_primitives() {
|
||||
assert_eq!(
|
||||
format_json_line(r#""hello""#),
|
||||
r#""hello""#,
|
||||
"JSON string should be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
format_json_line("42"),
|
||||
"42",
|
||||
"JSON number should be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
format_json_line("true"),
|
||||
"true",
|
||||
"JSON boolean should be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
format_json_line("null"),
|
||||
"null",
|
||||
"JSON null should be unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_line_whitespace_prefix() {
|
||||
let input = r#" {"a":1}"#;
|
||||
let result = format_json_line(input);
|
||||
assert!(
|
||||
result.contains('\n'),
|
||||
"JSON with leading whitespace should be formatted"
|
||||
);
|
||||
assert!(
|
||||
result.contains("\"a\""),
|
||||
"formatted result should contain key a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_cache_with_json_format() {
|
||||
let json_content = r#"{"key1":"value1","key2":"value2"}
|
||||
plain text line
|
||||
{"nested":{"inner":true}}
|
||||
"#;
|
||||
let path = make_temp_file(json_content);
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
app.content_height = 50;
|
||||
|
||||
// First without formatting
|
||||
app.recompute_wrap_cache(80);
|
||||
let raw_heights: Vec<usize> = app.visual_heights.clone();
|
||||
|
||||
// Now with formatting
|
||||
app.json_format = true;
|
||||
app.cached_width = 0;
|
||||
app.recompute_wrap_cache(80);
|
||||
|
||||
// JSON lines (0, 2) should have more visual rows when formatted
|
||||
assert!(
|
||||
app.visual_heights[0] > raw_heights[0],
|
||||
"JSON line 0 should have more visual rows when formatted"
|
||||
);
|
||||
assert_eq!(
|
||||
app.visual_heights[1], raw_heights[1],
|
||||
"Plain text line should have same visual rows"
|
||||
);
|
||||
assert!(
|
||||
app.visual_heights[2] > raw_heights[2],
|
||||
"JSON line 2 should have more visual rows when formatted"
|
||||
);
|
||||
|
||||
// Check that wrap_cache contains indentation
|
||||
let json_line_wrapped: String = app.wrap_cache[0].join("");
|
||||
assert!(
|
||||
json_line_wrapped.contains(" "),
|
||||
"Formatted JSON wrap cache should contain indentation"
|
||||
);
|
||||
});
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_cache_toggle_restore() {
|
||||
let json_content = r#"{"a":1,"b":2}
|
||||
"#;
|
||||
let path = make_temp_file(json_content);
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
app.content_height = 50;
|
||||
|
||||
// Compute raw wrap cache
|
||||
app.recompute_wrap_cache(80);
|
||||
let raw_cache: Vec<Vec<String>> = app.wrap_cache.clone();
|
||||
|
||||
// Toggle on: formatted
|
||||
app.json_format = true;
|
||||
app.cached_width = 0;
|
||||
app.recompute_wrap_cache(80);
|
||||
let formatted_cache: Vec<Vec<String>> = app.wrap_cache.clone();
|
||||
|
||||
// Formatted should differ from raw
|
||||
assert_ne!(
|
||||
raw_cache, formatted_cache,
|
||||
"Formatted wrap cache should differ from raw"
|
||||
);
|
||||
|
||||
// Toggle off: back to raw
|
||||
app.json_format = false;
|
||||
app.cached_width = 0;
|
||||
app.recompute_wrap_cache(80);
|
||||
let restored_cache: Vec<Vec<String>> = app.wrap_cache.clone();
|
||||
|
||||
assert_eq!(
|
||||
raw_cache, restored_cache,
|
||||
"Toggle off should restore original wrap cache"
|
||||
);
|
||||
});
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_toggle_keeps_cursor_visible() {
|
||||
let mut content = String::new();
|
||||
for i in 0..100 {
|
||||
content.push_str(&format!(
|
||||
r#"{{"line":{},"data":"value{}"}}"#,
|
||||
i, i
|
||||
));
|
||||
content.push('\n');
|
||||
}
|
||||
let path = make_temp_file(&content);
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
app.content_height = 20;
|
||||
|
||||
app.recompute_wrap_cache(80);
|
||||
assert_eq!(app.visual_heights[0], 1);
|
||||
|
||||
app.cursor_line = 50;
|
||||
app.ensure_cursor_visible();
|
||||
let cursor_visual = app.cursor_to_first_visual_row(50);
|
||||
assert!(
|
||||
cursor_visual >= app.v_offset
|
||||
&& cursor_visual < app.v_offset + app.content_height as usize,
|
||||
"cursor should be visible before Tab: cursor_visual={}, v_offset={}, content_height={}",
|
||||
cursor_visual, app.v_offset, app.content_height,
|
||||
);
|
||||
let v_offset_before = app.v_offset;
|
||||
|
||||
app.json_format = true;
|
||||
app.cached_width = 0;
|
||||
app.recompute_wrap_cache(80);
|
||||
|
||||
assert!(
|
||||
app.visual_heights[0] > 1,
|
||||
"JSON lines should expand when formatted"
|
||||
);
|
||||
|
||||
let cursor_visual_after = app.cursor_to_first_visual_row(50);
|
||||
assert!(
|
||||
cursor_visual_after >= app.v_offset
|
||||
&& cursor_visual_after
|
||||
< app.v_offset + app.content_height as usize,
|
||||
"cursor should be visible after Tab: cursor_visual={}, v_offset={}, content_height={}, v_offset_before={}",
|
||||
cursor_visual_after, app.v_offset, app.content_height, v_offset_before,
|
||||
);
|
||||
|
||||
assert_ne!(
|
||||
app.v_offset, v_offset_before,
|
||||
"v_offset should change after JSON formatting toggle (cursor was on line 50)"
|
||||
);
|
||||
});
|
||||
cleanup(&path);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user