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:
dailz
2026-04-11 11:38:30 +08:00
parent 8a664c02c8
commit 280c3f5014
3 changed files with 319 additions and 3 deletions

View File

@@ -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

View File

@@ -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());
}
}