From 280c3f501433a844e06d324eae3accde296d0897 Mon Sep 17 00:00:00 2001 From: dailz Date: Sat, 11 Apr 2026 11:38:30 +0800 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/tui/Cargo.toml | 2 +- crates/tui/src/app.rs | 319 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 319 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f3d5a5..8ec3d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2327,6 +2327,7 @@ dependencies = [ "crossterm", "log-viewer-core", "ratatui", + "serde_json", ] [[package]] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 8ce5d1a..30808c6 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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 diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index bf3dd7a..37f03b0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -42,6 +42,24 @@ fn wrap_line_chars(line: &str, width: usize) -> Vec { 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::(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, + + // 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 = 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> = app.wrap_cache.clone(); + + // Toggle on: formatted + app.json_format = true; + app.cached_width = 0; + app.recompute_wrap_cache(80); + let formatted_cache: Vec> = 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> = 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()); + } }