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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2327,6 +2327,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"log-viewer-core",
|
"log-viewer-core",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ crossterm.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log-viewer-core.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
|
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 struct App {
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
|
||||||
@@ -66,6 +84,9 @@ pub struct App {
|
|||||||
|
|
||||||
// gg state machine
|
// gg state machine
|
||||||
pub(crate) last_g_press: Option<Instant>,
|
pub(crate) last_g_press: Option<Instant>,
|
||||||
|
|
||||||
|
// JSON formatting
|
||||||
|
pub(crate) json_format: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -83,6 +104,7 @@ impl App {
|
|||||||
content_width: 0,
|
content_width: 0,
|
||||||
content_height: 0,
|
content_height: 0,
|
||||||
last_g_press: None,
|
last_g_press: None,
|
||||||
|
json_format: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +119,7 @@ impl App {
|
|||||||
self.total_visual_rows = 0;
|
self.total_visual_rows = 0;
|
||||||
self.cached_width = 0; // force wrap cache rebuild on next render
|
self.cached_width = 0; // force wrap cache rebuild on next render
|
||||||
self.last_g_press = None; // reset gg state machine
|
self.last_g_press = None; // reset gg state machine
|
||||||
|
self.json_format = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +138,19 @@ impl App {
|
|||||||
self.visual_heights.reserve(line_count);
|
self.visual_heights.reserve(line_count);
|
||||||
|
|
||||||
for i in 0..line_count {
|
for i in 0..line_count {
|
||||||
let line = self.get_line(i).unwrap_or("");
|
let raw = self.get_line(i).unwrap_or("");
|
||||||
let wrapped = wrap_line_chars(line, width);
|
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);
|
let height = wrapped.len().max(1);
|
||||||
self.wrap_cache.push(wrapped);
|
self.wrap_cache.push(wrapped);
|
||||||
self.visual_heights.push(height);
|
self.visual_heights.push(height);
|
||||||
@@ -130,6 +164,13 @@ impl App {
|
|||||||
.total_visual_rows
|
.total_visual_rows
|
||||||
.saturating_sub(self.content_height as usize);
|
.saturating_sub(self.content_height as usize);
|
||||||
self.v_offset = self.v_offset.min(max_offset);
|
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 ──────────────────────────────────────────────
|
// ── Scroll methods ──────────────────────────────────────────────
|
||||||
@@ -329,6 +370,11 @@ impl App {
|
|||||||
self.scroll_to_top();
|
self.scroll_to_top();
|
||||||
self.last_g_press = None;
|
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;
|
self.last_g_press = None;
|
||||||
}
|
}
|
||||||
@@ -581,4 +627,273 @@ mod tests {
|
|||||||
cleanup(&path);
|
cleanup(&path);
|
||||||
assert!(result.is_ok());
|
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