From 51d0cd43fab8964925111d75a0aef4604564dbb9 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Fri, 8 May 2026 16:52:50 +0200 Subject: [PATCH] feat(browse): add text and JSON viewers, list non-tabular files The browse subcommand previously hid non-tabular files entirely. List every entry instead and route by kind: tabular files (csv/tsv/parquet and JSON arrays) open in the existing DataFrame viewer, plain text and non-array JSON open in a new text viewer with scroll, search, line numbers, word wrap and pretty-printed JSON. Binary files render dimmed and surface a status message on Enter rather than failing silently. JSON routing peeks the root byte to decide: arrays go through polars, objects/scalars go to the text viewer (polars otherwise loads them as 1-row all-null DataFrames, which the original Err fall-through couldn't detect). Adds Suite Z to qa.sh covering the new keybindings and the JSON/binary routing. Also fixes two pre-existing QA assertions that referenced an old plot-mode shortcut bar label. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + Cargo.toml | 1 + qa.sh | 95 +++- src/browser/app.rs | 42 +- src/browser/azure.rs | 12 +- src/browser/events.rs | 102 +++- src/browser/local.rs | 47 +- src/browser/mod.rs | 129 +++++- src/browser/s3.rs | 12 +- src/browser/ui.rs | 40 +- src/config.rs | 5 + src/main.rs | 1 + src/text_viewer.rs | 766 +++++++++++++++++++++++++++++++ src/ui.rs | 2 +- tests/fixtures/sample.json | 1 + tests/fixtures/sample.txt | 19 + tests/fixtures/sample_binary.png | Bin 0 -> 30 bytes 17 files changed, 1182 insertions(+), 93 deletions(-) create mode 100644 src/text_viewer.rs create mode 100644 tests/fixtures/sample.json create mode 100644 tests/fixtures/sample.txt create mode 100644 tests/fixtures/sample_binary.png diff --git a/Cargo.lock b/Cargo.lock index ec239dc..8a7b619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,6 +610,7 @@ dependencies = [ "polars", "ratatui", "serde", + "serde_json", "tempfile", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 20b5279..44c8906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ polars = { version = "0.46", features = ["csv", "parquet", "lazy", "strings", "r clap = { version = "4", features = ["derive"] } dirs = "5" serde = { version = "1", features = ["derive"] } +serde_json = "1" toml = "0.8" object_store = { version = "0.11", optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } diff --git a/qa.sh b/qa.sh index c1ca85d..27480d1 100755 --- a/qa.sh +++ b/qa.sh @@ -331,7 +331,7 @@ start_app "tests/fixtures/orders.csv" # J1: single-Y plot — PlotPickY → PlotPickX → Plot send "lllllllll" # total_amount (col 9) send "p" 0.25 -assert_contains "J/picky-mode" "Space toggle" # in PlotPickY +assert_contains "J/picky-mode" "Toggle Y" # in PlotPickY assert_contains "J/picky-presel" "total_amount" # pre-selected in status bar enter 0.25 # confirm single Y, move to PlotPickX @@ -392,10 +392,10 @@ send "p" 0.25 enter 0.25 # go to PlotPickX esc sleep 0.15 -assert_contains "J/pickx-esc-back" "Space toggle" # back in PlotPickY +assert_contains "J/pickx-esc-back" "Toggle Y" # back in PlotPickY esc sleep 0.15 -assert_not_contains "J/picky-esc2" "Space toggle" +assert_not_contains "J/picky-esc2" "Toggle Y" quit @@ -667,6 +667,95 @@ send "q" 0.20 # Reset theme state so the next QA run (and the dev's normal use) starts fresh. rm -f "$STATE_FILE" +# ── Suite Z: Text viewer ────────────────────────────────────────────────────── +echo "" +echo "=== Suite Z: Text viewer ===" + +# Browser listing in tests/fixtures/ (alphabetical): +# 0 orders.csv 1 orders.json 2 orders.ndjson 3 orders.parquet +# 4 orders.tsv 5 orders_nulls.csv 6 sample.json 7 sample.txt +# 8 sample_binary.png 9 wide.csv + +# Z1: open sample.txt — text viewer renders content with UTF-8 title marker +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 # cursor → sample.txt +enter 0.40 +assert_contains "Z1/text-content" "Datasight text viewer fixture" +assert_contains "Z1/title-utf8" "UTF-8" +assert_contains "Z1/title-lines" "lines" +quit + +# Z2: j scrolls down, k scrolls back up — content remains rendered +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 +enter 0.40 +send "jjj" 0.20 +send "kkk" 0.20 +assert_contains "Z2/scroll-roundtrip" "Datasight text viewer fixture" +quit + +# Z3: G then gg returns to top of file (covered by unit tests; this is a +# smoke check that the keys don't crash the viewer) +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 +enter 0.40 +send "G" 0.30 +send "gg" 0.30 +assert_contains "Z3/back-to-top" "Datasight text viewer fixture" +quit + +# Z4: / opens search; typing a query shows match count; Enter jumps; n cycles +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 +enter 0.40 +send "/" 0.20 +send "needle" 0.30 +# Still in Search mode — bottom status row reports match count. +assert_contains "Z4/match-count" "match" +key Enter 0.30 +# Confirm scrolled to first occurrence of the token. +assert_contains "Z4/jumped-to-needle" "needle" +send "n" 0.20 +assert_contains "Z4/cycle-still-on-match" "needle" +quit + +# Z5: L toggles line numbers (rendering still succeeds, content still visible) +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 +enter 0.40 +send "L" 0.20 +assert_contains "Z5/text-still-visible" "Datasight text viewer" +send "L" 0.20 +assert_contains "Z5/text-after-retoggle" "Datasight text viewer" +quit + +# Z6: w toggles word wrap (rendering survives the toggle) +start_app "browse tests/fixtures/" +send "jjjjjjj" 0.30 +enter 0.40 +send "w" 0.20 +assert_contains "Z6/wrap-off-content" "Datasight text viewer" +send "w" 0.20 +assert_contains "Z6/wrap-on-content" "Datasight text viewer" +quit + +# Z7: non-tabular .json falls through to text viewer with pretty-print +start_app "browse tests/fixtures/" +send "jjjjjj" 0.30 # cursor → sample.json +enter 0.50 +# Pretty-printed JSON spans multiple lines; the title flags it as such. +assert_contains "Z7/json-pretty-title" "pretty JSON" +assert_contains "Z7/json-pretty-content" "version" +quit + +# Z8: opening a binary file shows a status message and does not load a viewer +start_app "browse tests/fixtures/" +send "jjjjjjjj" 0.30 # cursor → sample_binary.png +enter 0.40 +assert_contains "Z8/binary-status" "Cannot preview" +# Browser still has focus and no viewer loaded — q quits cleanly. +send "q" 0.20 + # ── Summary ──────────────────────────────────────────────────────────────────── echo "" echo "════════════════════════════════════════" diff --git a/src/browser/app.rs b/src/browser/app.rs index a1726f4..8669185 100644 --- a/src/browser/app.rs +++ b/src/browser/app.rs @@ -1,14 +1,46 @@ use crate::app::App; use crate::browser::{Entry, FileBrowser}; +use crate::text_viewer::TextApp; use crate::theme::Theme; use crate::theme_picker::ThemePicker; +/// One of the two viewer types loaded into the right-hand pane. The +/// dataframe variant wraps the existing tabular viewer; the text variant +/// drives `TextApp` for plain-text and pretty-printed JSON files. +pub enum Viewer { + DataFrame(Box), + Text(TextApp), +} + +impl Viewer { + pub fn set_theme(&mut self, theme: &'static Theme) { + match self { + Viewer::DataFrame(a) => a.theme = theme, + Viewer::Text(t) => t.theme = theme, + } + } + + pub fn is_typing(&self) -> bool { + match self { + Viewer::DataFrame(a) => a.is_typing(), + Viewer::Text(t) => t.is_typing(), + } + } + + pub fn should_quit(&self) -> bool { + match self { + Viewer::DataFrame(a) => a.should_quit, + Viewer::Text(t) => t.should_quit, + } + } +} + pub struct BrowserApp { pub backend: Box, pub entries: Vec, pub cursor: usize, pub cwd: String, - pub viewer: Option, + pub viewer: Option, pub browser_visible: bool, pub focus: Focus, pub status: Option, @@ -57,7 +89,7 @@ impl BrowserApp { /// Descend into the directory at the current cursor (no-op if it's a file). pub fn descend(&mut self) { if let Some(entry) = self.entries.get(self.cursor) { - if entry.is_dir { + if entry.is_dir() { let path = entry.path.clone(); self.refresh_listing(path); } @@ -115,7 +147,7 @@ fn parent_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::browser::{BrowserError, Entry, FileBrowser}; + use crate::browser::{BrowserError, Entry, EntryKind, FileBrowser}; struct StubBackend { entries: Vec, @@ -145,9 +177,9 @@ mod tests { fn file_entry(name: &str) -> Entry { Entry { + kind: crate::browser::classify(name), name: name.to_string(), path: format!("/test/{}", name), - is_dir: false, } } @@ -155,7 +187,7 @@ mod tests { Entry { name: name.to_string(), path: format!("/test/{}", name), - is_dir: true, + kind: EntryKind::Dir, } } diff --git a/src/browser/azure.rs b/src/browser/azure.rs index 1a72fa9..88a87d0 100644 --- a/src/browser/azure.rs +++ b/src/browser/azure.rs @@ -1,4 +1,4 @@ -use crate::browser::{is_supported, BrowserError, Entry, FileBrowser}; +use crate::browser::{classify, BrowserError, Entry, EntryKind, FileBrowser}; use object_store::azure::MicrosoftAzureBuilder; use object_store::path::Path; use object_store::ObjectStore; @@ -92,23 +92,23 @@ impl FileBrowser for AzureBackend { entries.push(Entry { name: name.clone(), path: format!("az://{}/{}/", self.container, dir_path), - is_dir: true, + kind: EntryKind::Dir, }); } for obj in list_result.objects { let name = obj.location.filename().unwrap_or("").to_string(); - if name.is_empty() || !is_supported(&name) { + if name.is_empty() { continue; } entries.push(Entry { - name: name.clone(), + kind: classify(&name), path: format!("az://{}/{}", self.container, obj.location), - is_dir: false, + name, }); } - entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))); + entries.sort_by(|a, b| b.is_dir().cmp(&a.is_dir()).then(a.name.cmp(&b.name))); Ok(entries) } diff --git a/src/browser/events.rs b/src/browser/events.rs index d7f9b29..da3e99e 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -1,8 +1,10 @@ use crate::app::App; -use crate::browser::app::{BrowserApp, Focus}; -use crate::browser::load_file_for_browser; +use crate::browser::app::{BrowserApp, Focus, Viewer}; use crate::browser::ui::browser_ui; +use crate::browser::{load_file_for_browser, EntryKind, FileBrowser}; +use crate::config::MAX_TEXT_BYTES; use crate::events::dispatch_viewer_key; +use crate::text_viewer::{dispatch_text_viewer_key, load_text, TextApp, TextLoadError}; use crossterm::event::{self, KeyModifiers}; pub fn run_browser_app( @@ -23,14 +25,14 @@ pub fn run_browser_app( let next = picker.move_down(); app.theme = next; if let Some(ref mut viewer) = app.viewer { - viewer.theme = next; + viewer.set_theme(next); } } event::KeyCode::Char('k') | event::KeyCode::Up => { let prev = picker.move_up(); app.theme = prev; if let Some(ref mut viewer) = app.viewer { - viewer.theme = prev; + viewer.set_theme(prev); } } event::KeyCode::Enter => { @@ -47,7 +49,7 @@ pub fn run_browser_app( let original = picker.original_theme(); app.theme = original; if let Some(ref mut viewer) = app.viewer { - viewer.theme = original; + viewer.set_theme(original); } app.picker = None; } @@ -92,8 +94,11 @@ pub fn run_browser_app( Focus::Browser => handle_browser_key(&mut app, &key), Focus::Viewer => { if let Some(ref mut viewer) = app.viewer { - dispatch_viewer_key(viewer, &key); - if viewer.should_quit { + match viewer { + Viewer::DataFrame(a) => dispatch_viewer_key(a, &key), + Viewer::Text(t) => dispatch_text_viewer_key(t, &key), + } + if viewer.should_quit() { app.should_quit = true; } } @@ -121,19 +126,80 @@ fn open_or_descend(app: &mut BrowserApp) { None => return, }; - if entry.is_dir { - app.descend(); - } else { - match load_file_for_browser(&entry.path, app.backend.as_ref()) { - Ok((df, title)) => { - app.viewer = Some(App::new(df, title, app.theme)); - app.focus = Focus::Viewer; - app.status = None; + match entry.kind { + EntryKind::Dir => app.descend(), + EntryKind::Binary => { + app.status = Some(format!("Cannot preview {}: binary file", entry.name)); + } + EntryKind::Data => { + let ext = entry.name.rsplit('.').next().unwrap_or("").to_lowercase(); + + // Polars happily loads object-rooted JSON as a 1-row all-null + // DataFrame instead of erroring, so the Err fall-through alone + // can't distinguish tabular from non-tabular .json. Peek the + // root byte: only arrays go to the DataFrame viewer; objects + // and scalars route directly to the text viewer with pretty + // printing. .ndjson/.jsonl skip this check — each line is its + // own document. + if ext == "json" && peek_json_root(&entry.path, app.backend.as_ref()) != Some(b'[') { + open_as_text(app, &entry.path, &entry.name); + return; } - Err(e) => { - app.viewer = None; - app.status = Some(format!("Error loading file: {}", e)); + + match load_file_for_browser(&entry.path, app.backend.as_ref()) { + Ok((df, title)) => { + app.viewer = Some(Viewer::DataFrame(Box::new(App::new(df, title, app.theme)))); + app.focus = Focus::Viewer; + app.status = None; + } + Err(e) => { + // Polars couldn't parse a JSON array (malformed). Fall + // through to text viewer rather than blocking the user. + if ext == "json" { + open_as_text(app, &entry.path, &entry.name); + } else { + app.status = Some(format!("Error loading file: {}", e)); + } + } } } + EntryKind::Text => open_as_text(app, &entry.path, &entry.name), + } +} + +/// Returns the first non-whitespace byte of the file at `path`, or `None` +/// if the file can't be read or is whitespace-only. For local paths this +/// reads at most 4 KiB; for cloud paths it goes through the backend's +/// `download_bytes` (which currently fetches the whole object). +fn peek_json_root(path: &str, backend: &dyn FileBrowser) -> Option { + let bytes = if path.starts_with("az://") || path.starts_with("s3://") { + backend.download_bytes(path).ok()? + } else { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; 4096]; + let n = file.read(&mut buf).ok()?; + buf[..n].to_vec() + }; + bytes.iter().copied().find(|b| !b.is_ascii_whitespace()) +} + +fn open_as_text(app: &mut BrowserApp, path: &str, name: &str) { + match load_text(path, app.backend.as_ref(), MAX_TEXT_BYTES) { + Ok(load) => { + app.viewer = Some(Viewer::Text(TextApp::new( + load, + path.to_string(), + app.theme, + ))); + app.focus = Focus::Viewer; + app.status = None; + } + Err(TextLoadError::Binary) => { + app.status = Some(format!("Cannot preview {}: not a text file", name)); + } + Err(TextLoadError::Io(e)) => { + app.status = Some(format!("Error loading file: {}", e)); + } } } diff --git a/src/browser/local.rs b/src/browser/local.rs index f4badfb..210ee1e 100644 --- a/src/browser/local.rs +++ b/src/browser/local.rs @@ -1,4 +1,4 @@ -use crate::browser::{is_supported, BrowserError, Entry, FileBrowser}; +use crate::browser::{classify, BrowserError, Entry, EntryKind, FileBrowser}; use std::fs; pub struct LocalBackend; @@ -24,13 +24,13 @@ impl FileBrowser for LocalBackend { dirs.push(Entry { name, path, - is_dir: true, + kind: EntryKind::Dir, }); - } else if is_supported(&name) { + } else { files.push(Entry { + kind: classify(&name), name, path, - is_dir: false, }); } } @@ -47,14 +47,14 @@ mod tests { use super::*; #[test] - fn test_list_fixtures_only_supported_formats() { + fn test_list_fixtures_classifies_data_files() { let backend = LocalBackend; let entries = backend.list("tests/fixtures").expect("list should succeed"); - for e in &entries { - if !e.is_dir { - assert!(is_supported(&e.name), "unexpected file: {}", e.name); - } - } + let csv = entries + .iter() + .find(|e| e.name == "orders.csv") + .expect("orders.csv missing"); + assert_eq!(csv.kind, EntryKind::Data); } #[test] @@ -62,7 +62,9 @@ mod tests { let backend = LocalBackend; let entries = backend.list("tests/fixtures").expect("list should succeed"); assert!( - entries.iter().any(|e| e.name == "orders.csv" && !e.is_dir), + entries + .iter() + .any(|e| e.name == "orders.csv" && !e.is_dir()), "orders.csv not found in listing" ); } @@ -79,8 +81,8 @@ mod tests { .list(tmp.to_str().unwrap()) .expect("list should succeed"); assert_eq!(entries.len(), 2, "expected exactly 1 dir + 1 file"); - assert!(entries[0].is_dir, "directory should appear before file"); - assert!(!entries[1].is_dir, "file should appear after directory"); + assert!(entries[0].is_dir(), "directory should appear before file"); + assert!(!entries[1].is_dir(), "file should appear after directory"); let _ = std::fs::remove_dir_all(&tmp); } @@ -106,6 +108,25 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn test_list_includes_text_and_binary_files() { + let tmp = std::env::temp_dir().join("datasight_test_kinds"); + let _ = std::fs::remove_dir_all(&tmp); + let _ = std::fs::create_dir_all(&tmp); + let _ = std::fs::write(tmp.join("data.csv"), "a,b\n1,2"); + let _ = std::fs::write(tmp.join("notes.txt"), "hi"); + let _ = std::fs::write(tmp.join("photo.png"), [0u8, 1, 2, 3]); + let backend = LocalBackend; + let entries = backend + .list(tmp.to_str().unwrap()) + .expect("list should succeed"); + let by_name = |n: &str| entries.iter().find(|e| e.name == n).map(|e| e.kind); + assert_eq!(by_name("data.csv"), Some(EntryKind::Data)); + assert_eq!(by_name("notes.txt"), Some(EntryKind::Text)); + assert_eq!(by_name("photo.png"), Some(EntryKind::Binary)); + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn test_list_nonexistent_path_errors() { let backend = LocalBackend; diff --git a/src/browser/mod.rs b/src/browser/mod.rs index 8e4e544..8eb2be4 100644 --- a/src/browser/mod.rs +++ b/src/browser/mod.rs @@ -25,13 +25,30 @@ pub trait FileBrowser { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Dir, + /// Tabular file the polars-backed viewer can open. + Data, + /// Probably-text file: opened in the text viewer with a UTF-8 sniff. + Text, + /// Known-binary extension; rendered grayed out and refused at open time. + Binary, +} + #[derive(Debug, Clone)] pub struct Entry { /// Display name — last path segment only. pub name: String, /// Full URI, e.g. "az://container/data/sales.csv" or "/home/user/data.csv". pub path: String, - pub is_dir: bool, + pub kind: EntryKind, +} + +impl Entry { + pub fn is_dir(&self) -> bool { + self.kind == EntryKind::Dir + } } #[derive(Debug)] @@ -57,13 +74,36 @@ impl fmt::Display for BrowserError { impl std::error::Error for BrowserError {} -pub const SUPPORTED_EXTENSIONS: &[&str] = &["csv", "tsv", "parquet", "json", "ndjson", "jsonl"]; +pub const DATA_EXTENSIONS: &[&str] = &["csv", "tsv", "parquet", "json", "ndjson", "jsonl"]; + +/// Extensions classified as binary — rendered grayed out, refused at open time. +/// Anything outside both lists falls into [`EntryKind::Text`] and is sniffed +/// for UTF-8 content when the user opens it. +pub const BINARY_EXTENSIONS: &[&str] = &[ + // Executables / compiled artifacts + "exe", "bin", "so", "dll", "dylib", "o", "a", "out", "class", "jar", "wasm", + // Images + "png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff", "svgz", // Audio / video + "mp3", "mp4", "wav", "mov", "avi", "mkv", "flac", "ogg", "webm", "m4a", "m4v", + // Archives + "zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", // Documents / fonts + "pdf", "ttf", "otf", "woff", "woff2", "eot", // Local databases + "db", "sqlite", +]; -pub fn is_supported(name: &str) -> bool { - let lower = name.to_lowercase(); - SUPPORTED_EXTENSIONS - .iter() - .any(|ext| lower.ends_with(&format!(".{}", ext))) +/// Classify a file by name. Comparison is case-insensitive on the extension. +/// Files without a recognised extension fall through to [`EntryKind::Text`]. +pub fn classify(name: &str) -> EntryKind { + let ext = name + .rsplit('.') + .next() + .filter(|e| *e != name) + .map(|e| e.to_lowercase()); + match ext.as_deref() { + Some(e) if DATA_EXTENSIONS.contains(&e) => EntryKind::Data, + Some(e) if BINARY_EXTENSIONS.contains(&e) => EntryKind::Binary, + _ => EntryKind::Text, + } } /// Detect the URI scheme and construct the appropriate backend. @@ -122,43 +162,86 @@ mod tests { use super::*; #[test] - fn test_is_supported_csv() { - assert!(is_supported("orders.csv")); + fn test_classify_csv_is_data() { + assert_eq!(classify("orders.csv"), EntryKind::Data); + } + + #[test] + fn test_classify_parquet_is_data() { + assert_eq!(classify("data.parquet"), EntryKind::Data); + } + + #[test] + fn test_classify_tsv_is_data() { + assert_eq!(classify("data.tsv"), EntryKind::Data); + } + + #[test] + fn test_classify_ndjson_is_data() { + assert_eq!(classify("data.ndjson"), EntryKind::Data); + } + + #[test] + fn test_classify_jsonl_is_data() { + assert_eq!(classify("data.jsonl"), EntryKind::Data); + } + + #[test] + fn test_classify_json_is_data() { + assert_eq!(classify("events.json"), EntryKind::Data); + } + + #[test] + fn test_classify_png_is_binary() { + assert_eq!(classify("photo.png"), EntryKind::Binary); + } + + #[test] + fn test_classify_zip_is_binary() { + assert_eq!(classify("archive.zip"), EntryKind::Binary); + } + + #[test] + fn test_classify_pdf_is_binary() { + assert_eq!(classify("doc.pdf"), EntryKind::Binary); } #[test] - fn test_is_supported_parquet() { - assert!(is_supported("data.parquet")); + fn test_classify_xlsx_is_text() { + // .xlsx isn't in the binary denylist so it falls through to Text; + // the open-time UTF-8 sniff will reject it as not-text. + assert_eq!(classify("report.xlsx"), EntryKind::Text); } #[test] - fn test_is_supported_tsv() { - assert!(is_supported("data.tsv")); + fn test_classify_txt_is_text() { + assert_eq!(classify("notes.txt"), EntryKind::Text); } #[test] - fn test_is_supported_ndjson() { - assert!(is_supported("data.ndjson")); + fn test_classify_md_is_text() { + assert_eq!(classify("README.md"), EntryKind::Text); } #[test] - fn test_is_supported_jsonl() { - assert!(is_supported("data.jsonl")); + fn test_classify_yaml_is_text() { + assert_eq!(classify("config.yaml"), EntryKind::Text); } #[test] - fn test_is_supported_json() { - assert!(is_supported("events.json")); + fn test_classify_log_is_text() { + assert_eq!(classify("server.log"), EntryKind::Text); } #[test] - fn test_is_supported_rejects_xlsx() { - assert!(!is_supported("report.xlsx")); + fn test_classify_no_ext_is_text() { + assert_eq!(classify("README"), EntryKind::Text); } #[test] - fn test_is_supported_rejects_no_ext() { - assert!(!is_supported("README")); + fn test_classify_uppercase_extension() { + assert_eq!(classify("DATA.CSV"), EntryKind::Data); + assert_eq!(classify("PHOTO.PNG"), EntryKind::Binary); } #[test] diff --git a/src/browser/s3.rs b/src/browser/s3.rs index 0243d56..96c7322 100644 --- a/src/browser/s3.rs +++ b/src/browser/s3.rs @@ -1,4 +1,4 @@ -use crate::browser::{is_supported, BrowserError, Entry, FileBrowser}; +use crate::browser::{classify, BrowserError, Entry, EntryKind, FileBrowser}; use object_store::aws::AmazonS3Builder; use object_store::path::Path; use object_store::ObjectStore; @@ -59,23 +59,23 @@ impl FileBrowser for S3Backend { entries.push(Entry { name: name.clone(), path: format!("s3://{}/{}/", self.bucket, dir_path), - is_dir: true, + kind: EntryKind::Dir, }); } for obj in list_result.objects { let name = obj.location.filename().unwrap_or("").to_string(); - if name.is_empty() || !is_supported(&name) { + if name.is_empty() { continue; } entries.push(Entry { - name: name.clone(), + kind: classify(&name), path: format!("s3://{}/{}", self.bucket, obj.location), - is_dir: false, + name, }); } - entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))); + entries.sort_by(|a, b| b.is_dir().cmp(&a.is_dir()).then(a.name.cmp(&b.name))); Ok(entries) } diff --git a/src/browser/ui.rs b/src/browser/ui.rs index a79cce0..aabef78 100644 --- a/src/browser/ui.rs +++ b/src/browser/ui.rs @@ -1,4 +1,5 @@ -use crate::browser::app::{BrowserApp, Focus}; +use crate::browser::app::{BrowserApp, Focus, Viewer}; +use crate::text_viewer::render_text_viewer; use crate::theme::Theme; use crate::ui::ui; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; @@ -65,10 +66,11 @@ fn render_browser_pane(frame: &mut Frame, app: &BrowserApp, area: Rect, theme: & .entries .iter() .map(|entry| { - let style = if entry.is_dir { - Style::default().fg(theme.accent) - } else { - Style::default().fg(theme.fg) + let style = match entry.kind { + crate::browser::EntryKind::Dir => Style::default().fg(theme.accent), + crate::browser::EntryKind::Data => Style::default().fg(theme.fg), + crate::browser::EntryKind::Text => Style::default().fg(theme.fg), + crate::browser::EntryKind::Binary => Style::default().fg(theme.fg_dim), }; ListItem::new(entry.name.clone()).style(style) }) @@ -87,18 +89,20 @@ fn render_browser_pane(frame: &mut Frame, app: &BrowserApp, area: Rect, theme: & } fn render_viewer_pane(frame: &mut Frame, app: &mut BrowserApp, area: Rect, theme: &Theme) { - if let Some(ref mut viewer) = app.viewer { - ui(frame, viewer, area); - } else { - let hint = Paragraph::new("Navigate to a file and press Enter to open it") - .alignment(Alignment::Center) - .style(Style::default().fg(theme.fg_muted)) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.border_idle)), - ); - frame.render_widget(hint, area); + match app.viewer { + Some(Viewer::DataFrame(ref mut a)) => ui(frame, a, area), + Some(Viewer::Text(ref mut t)) => render_text_viewer(frame, t, area, theme), + None => { + let hint = Paragraph::new("Navigate to a file and press Enter to open it") + .alignment(Alignment::Center) + .style(Style::default().fg(theme.fg_muted)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_idle)), + ); + frame.render_widget(hint, area); + } } } @@ -260,7 +264,7 @@ mod tests { let viewer = crate::app::App::new(df, "test.csv".to_string(), crate::theme::default_theme()); let mut app = make_app(); - app.viewer = Some(viewer); + app.viewer = Some(Viewer::DataFrame(Box::new(viewer))); let text = bar_text(&app); assert!(text.contains("j / k"), "expected 'j / k' in: {}", text); assert!( diff --git a/src/config.rs b/src/config.rs index 2fb7fc5..83aa604 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,3 +11,8 @@ pub const Y_AXIS_PADDING: f64 = 0.05; pub const Y_AXIS_TICKS: usize = 5; pub const CHART_BORDER_WIDTH: u16 = 1; pub const MAX_UNIQUE: usize = 500; + +/// Cap on the byte slice the text viewer will load from a single file. +/// Files larger than this are read up to this point and shown with a +/// "truncated, X MB total" banner — nothing beyond the cap is read. +pub const MAX_TEXT_BYTES: usize = 10 * 1024 * 1024; diff --git a/src/main.rs b/src/main.rs index ea16149..4b72c38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod browser; mod config; mod events; +mod text_viewer; mod theme; mod theme_picker; mod ui; diff --git a/src/text_viewer.rs b/src/text_viewer.rs new file mode 100644 index 0000000..da9ac52 --- /dev/null +++ b/src/text_viewer.rs @@ -0,0 +1,766 @@ +use crate::browser::FileBrowser; +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; + +const PAGE_SCROLL_LINES: usize = 20; + +#[derive(Debug, PartialEq)] +pub enum TextMode { + Normal, + Search, +} + +#[derive(Default, Debug)] +pub struct TextSearchState { + pub query: String, + pub matches: Vec<(usize, usize)>, + pub cursor: usize, +} + +pub struct TextApp { + pub title: String, + pub theme: &'static Theme, + pub should_quit: bool, + + lines: Vec, + truncated: Option, + is_pretty_json: bool, + + row_offset: usize, + col_offset: usize, + wrap: bool, + show_line_numbers: bool, + + search: TextSearchState, + mode: TextMode, + + /// Tracks pending `g` keypress for the `gg` two-key sequence. + last_g: bool, +} + +#[derive(Debug)] +pub struct TextLoad { + pub lines: Vec, + pub truncated: Option, + pub is_pretty_json: bool, +} + +#[derive(Debug)] +pub enum TextLoadError { + Binary, + Io(String), +} + +impl std::fmt::Display for TextLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TextLoadError::Binary => write!(f, "not a text file"), + TextLoadError::Io(s) => write!(f, "{}", s), + } + } +} + +impl std::error::Error for TextLoadError {} + +pub fn load_text( + path: &str, + backend: &dyn FileBrowser, + max_bytes: usize, +) -> Result { + let bytes = read_bytes(path, backend).map_err(TextLoadError::Io)?; + let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); + parse_text(&bytes, &ext, max_bytes) +} + +fn read_bytes(path: &str, backend: &dyn FileBrowser) -> Result, String> { + if path.starts_with("az://") || path.starts_with("s3://") { + backend.download_bytes(path).map_err(|e| e.to_string()) + } else { + std::fs::read(path).map_err(|e| e.to_string()) + } +} + +/// Pure byte → TextLoad transformation. Split out so it can be unit-tested +/// without touching the filesystem or a backend. +pub fn parse_text(bytes: &[u8], ext: &str, max_bytes: usize) -> Result { + let total = bytes.len() as u64; + let truncated = if (total as usize) > max_bytes { + Some(total) + } else { + None + }; + let slice = if truncated.is_some() { + &bytes[..max_bytes] + } else { + bytes + }; + let s = std::str::from_utf8(slice).map_err(|_| TextLoadError::Binary)?; + + // Pretty-print JSON only when the file fit fully (truncating mid-JSON + // would yield a parse error and leave the user with raw, half-loaded + // bytes). For .ndjson/.jsonl we never pretty-print: each line is its + // own document and serde would refuse the whole-file payload anyway. + let (content, is_pretty_json) = if ext == "json" && truncated.is_none() { + match serde_json::from_str::(s) { + Ok(v) => ( + serde_json::to_string_pretty(&v).unwrap_or_else(|_| s.to_string()), + true, + ), + Err(_) => (s.to_string(), false), + } + } else { + (s.to_string(), false) + }; + + let lines: Vec = content.split('\n').map(|s| s.to_string()).collect(); + Ok(TextLoad { + lines, + truncated, + is_pretty_json, + }) +} + +impl TextApp { + pub fn new(load: TextLoad, title: String, theme: &'static Theme) -> Self { + Self { + title, + theme, + should_quit: false, + lines: load.lines, + truncated: load.truncated, + is_pretty_json: load.is_pretty_json, + row_offset: 0, + col_offset: 0, + wrap: true, + show_line_numbers: true, + search: TextSearchState::default(), + mode: TextMode::Normal, + last_g: false, + } + } + + pub fn is_typing(&self) -> bool { + self.mode == TextMode::Search + } + + fn line_count(&self) -> usize { + self.lines.len() + } + + fn scroll_down(&mut self, n: usize) { + let max = self.line_count().saturating_sub(1); + self.row_offset = (self.row_offset + n).min(max); + } + + fn scroll_up(&mut self, n: usize) { + self.row_offset = self.row_offset.saturating_sub(n); + } + + fn go_top(&mut self) { + self.row_offset = 0; + } + + fn go_bottom(&mut self) { + self.row_offset = self.line_count().saturating_sub(1); + } + + fn h_scroll_right(&mut self) { + self.col_offset = self.col_offset.saturating_add(1); + } + + fn h_scroll_left(&mut self) { + self.col_offset = self.col_offset.saturating_sub(1); + } + + fn toggle_wrap(&mut self) { + self.wrap = !self.wrap; + if self.wrap { + self.col_offset = 0; + } + } + + fn toggle_line_numbers(&mut self) { + self.show_line_numbers = !self.show_line_numbers; + } + + fn enter_search(&mut self) { + self.mode = TextMode::Search; + self.search.query.clear(); + self.search.matches.clear(); + self.search.cursor = 0; + } + + fn exit_search(&mut self) { + self.mode = TextMode::Normal; + self.search.query.clear(); + self.search.matches.clear(); + self.search.cursor = 0; + } + + fn confirm_search(&mut self) { + self.mode = TextMode::Normal; + self.recompute_matches(); + if let Some(&(row, _)) = self.search.matches.first() { + self.row_offset = row; + self.search.cursor = 0; + } + } + + fn recompute_matches(&mut self) { + self.search.matches.clear(); + self.search.cursor = 0; + if self.search.query.is_empty() { + return; + } + let q = self.search.query.to_lowercase(); + for (i, line) in self.lines.iter().enumerate() { + let lower = line.to_lowercase(); + let mut start = 0; + while let Some(pos) = lower[start..].find(&q) { + let abs = start + pos; + self.search.matches.push((i, abs)); + // Step past the match start so overlapping occurrences still + // surface (e.g. searching "aa" in "aaaa" yields three hits). + start = abs + 1; + if start >= lower.len() { + break; + } + } + } + } + + fn next_match(&mut self) { + if self.search.matches.is_empty() { + return; + } + self.search.cursor = (self.search.cursor + 1) % self.search.matches.len(); + if let Some(&(row, _)) = self.search.matches.get(self.search.cursor) { + self.row_offset = row; + } + } + + fn prev_match(&mut self) { + if self.search.matches.is_empty() { + return; + } + self.search.cursor = if self.search.cursor == 0 { + self.search.matches.len() - 1 + } else { + self.search.cursor - 1 + }; + if let Some(&(row, _)) = self.search.matches.get(self.search.cursor) { + self.row_offset = row; + } + } + + #[cfg(test)] + fn row_offset(&self) -> usize { + self.row_offset + } + + #[cfg(test)] + fn col_offset(&self) -> usize { + self.col_offset + } + + #[cfg(test)] + fn wrap(&self) -> bool { + self.wrap + } + + #[cfg(test)] + fn show_line_numbers(&self) -> bool { + self.show_line_numbers + } + + #[cfg(test)] + fn mode(&self) -> &TextMode { + &self.mode + } + + #[cfg(test)] + fn matches_len(&self) -> usize { + self.search.matches.len() + } +} + +pub fn dispatch_text_viewer_key(app: &mut TextApp, key: &KeyEvent) { + match app.mode { + TextMode::Normal => handle_normal_key(app, key), + TextMode::Search => handle_search_key(app, key), + } +} + +fn handle_normal_key(app: &mut TextApp, key: &KeyEvent) { + let was_g = app.last_g; + app.last_g = false; + match key.code { + KeyCode::Char('j') | KeyCode::Down => app.scroll_down(1), + KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1), + KeyCode::PageDown => app.scroll_down(PAGE_SCROLL_LINES), + KeyCode::PageUp => app.scroll_up(PAGE_SCROLL_LINES), + KeyCode::Char('g') => { + if was_g { + app.go_top(); + } else { + app.last_g = true; + } + } + KeyCode::Char('G') => app.go_bottom(), + KeyCode::Char('h') | KeyCode::Left if !app.wrap => app.h_scroll_left(), + KeyCode::Char('l') | KeyCode::Right if !app.wrap => app.h_scroll_right(), + KeyCode::Char('w') => app.toggle_wrap(), + KeyCode::Char('L') => app.toggle_line_numbers(), + KeyCode::Char('/') => app.enter_search(), + KeyCode::Char('n') => app.next_match(), + KeyCode::Char('N') => app.prev_match(), + KeyCode::Char('q') => app.should_quit = true, + _ => {} + } +} + +fn handle_search_key(app: &mut TextApp, key: &KeyEvent) { + match key.code { + KeyCode::Char(c) => { + app.search.query.push(c); + app.recompute_matches(); + } + KeyCode::Backspace => { + app.search.query.pop(); + app.recompute_matches(); + } + KeyCode::Enter => app.confirm_search(), + KeyCode::Esc => app.exit_search(), + _ => {} + } +} + +pub fn render_text_viewer(frame: &mut Frame, app: &mut TextApp, area: Rect, theme: &Theme) { + let title_text = build_title(app); + let block = Block::default() + .title(Line::from(title_text).alignment(Alignment::Left)) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_idle)) + .style(Style::default().bg(theme.bg)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Reserve a single status row when search is active or matches are pinned. + let body_area = if app.mode == TextMode::Search { + let [body, status] = + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); + let count = app.search.matches.len(); + let suffix = if count > 0 { + format!(" — {} match{}", count, if count == 1 { "" } else { "es" }) + } else if app.search.query.is_empty() { + " — type to search, Enter to jump, Esc to cancel".to_string() + } else { + " — no matches".to_string() + }; + let q = format!(" /{}_", app.search.query); + let style = Style::default() + .fg(theme.bg) + .bg(theme.warn) + .add_modifier(Modifier::BOLD); + frame.render_widget( + Paragraph::new(format!("{}{}", q, suffix)).style(style), + status, + ); + body + } else if !app.search.matches.is_empty() { + let [body, status] = + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); + let n = app.search.matches.len(); + let cursor = app.search.cursor + 1; + let info = format!( + " {}/{} for /{}/ — n next, N prev ", + cursor, n, app.search.query + ); + let style = Style::default().fg(theme.fg_dim).bg(theme.bg_alt); + frame.render_widget(Paragraph::new(info).style(style), status); + body + } else { + inner + }; + + // Width of the line-number gutter (digits + 1 trailing space). + let line_num_width = if app.show_line_numbers { + let n = app.lines.len().max(1); + ((n as f64).log10().floor() as usize) + 1 + } else { + 0 + }; + + let visible_h = body_area.height as usize; + let start = app.row_offset; + let end = (start + visible_h).min(app.lines.len()); + + // Build all visible lines as a single ratatui Lines vec — line numbers + // rendered as a styled prefix span so wrap behaviour stays consistent. + let mut text_lines: Vec = Vec::with_capacity(end.saturating_sub(start)); + for i in start..end { + let mut spans: Vec = Vec::new(); + if app.show_line_numbers { + spans.push(Span::styled( + format!("{:>width$} ", i + 1, width = line_num_width), + Style::default().fg(theme.fg_dim), + )); + } + spans.extend(highlight_spans(&app.lines[i], &app.search.query, theme)); + text_lines.push(Line::from(spans)); + } + + let mut paragraph = Paragraph::new(text_lines).style(Style::default().fg(theme.fg)); + if app.wrap { + paragraph = paragraph.wrap(Wrap { trim: false }); + } else { + paragraph = paragraph.scroll((0, app.col_offset as u16)); + } + frame.render_widget(paragraph, body_area); +} + +fn build_title(app: &TextApp) -> String { + let mut parts = vec![app.title.clone(), format!("{} lines", app.lines.len())]; + if app.is_pretty_json { + parts.push("pretty JSON".to_string()); + } + if let Some(total) = app.truncated { + let mb = total as f64 / (1024.0 * 1024.0); + parts.push(format!("truncated, {:.1} MB total", mb)); + } + parts.push("UTF-8".to_string()); + format!(" {} ", parts.join(" · ")) +} + +fn highlight_spans(text: &str, query: &str, theme: &Theme) -> Vec> { + if query.is_empty() { + return vec![Span::raw(text.to_string())]; + } + let q = query.to_lowercase(); + let lower = text.to_lowercase(); + let hit_style = Style::default() + .fg(theme.bg) + .bg(theme.warn) + .add_modifier(Modifier::BOLD); + + let mut spans: Vec> = Vec::new(); + let mut last = 0; + let mut start = 0; + while let Some(pos) = lower[start..].find(&q) { + let abs = start + pos; + if abs > last { + spans.push(Span::raw(text[last..abs].to_string())); + } + let end = abs + q.len(); + spans.push(Span::styled(text[abs..end].to_string(), hit_style)); + last = end; + start = end; + if start >= lower.len() { + break; + } + } + if last < text.len() { + spans.push(Span::raw(text[last..].to_string())); + } + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + fn theme() -> &'static Theme { + crate::theme::default_theme() + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn make_app(content: &str) -> TextApp { + let bytes = content.as_bytes(); + let load = parse_text(bytes, "txt", 1024 * 1024).expect("parse"); + TextApp::new(load, "test.txt".to_string(), theme()) + } + + // ── parse_text ──────────────────────────────────────────────────────── + + #[test] + fn parse_text_plain_utf8_succeeds() { + let load = parse_text(b"hello\nworld\n", "txt", 1024).expect("ok"); + assert_eq!(load.lines, vec!["hello", "world", ""]); + assert!(load.truncated.is_none()); + assert!(!load.is_pretty_json); + } + + #[test] + fn parse_text_non_utf8_returns_binary() { + // Lone 0xff is invalid UTF-8. + let bytes = [0xff_u8, 0xfe, 0xfd]; + let err = parse_text(&bytes, "txt", 1024).unwrap_err(); + assert!(matches!(err, TextLoadError::Binary)); + } + + #[test] + fn parse_text_truncates_when_over_cap() { + let big = "a".repeat(2048); + let load = parse_text(big.as_bytes(), "txt", 1024).expect("ok"); + assert_eq!(load.truncated, Some(2048)); + assert_eq!(load.lines.iter().map(|s| s.len()).sum::(), 1024); + } + + #[test] + fn parse_text_does_not_truncate_when_under_cap() { + let load = parse_text(b"hi", "txt", 1024).expect("ok"); + assert!(load.truncated.is_none()); + } + + #[test] + fn parse_text_pretty_prints_valid_json() { + let load = parse_text(br#"{"a":1,"b":2}"#, "json", 1024).expect("ok"); + assert!(load.is_pretty_json); + // Pretty-printed output spans multiple lines. + assert!(load.lines.len() > 1); + } + + #[test] + fn parse_text_leaves_invalid_json_as_raw_text() { + let load = parse_text(b"{not valid json", "json", 1024).expect("ok"); + assert!(!load.is_pretty_json); + } + + #[test] + fn parse_text_does_not_pretty_print_truncated_json() { + // Even valid JSON is left as raw text when truncated, since slicing + // mid-document would break the parser. + let big = format!("[{}]", "1,".repeat(5000)); + let load = parse_text(big.as_bytes(), "json", 100).expect("ok"); + assert!(load.truncated.is_some()); + assert!(!load.is_pretty_json); + } + + #[test] + fn parse_text_does_not_pretty_print_ndjson() { + let load = parse_text(b"{\"a\":1}\n{\"a\":2}\n", "ndjson", 1024).expect("ok"); + assert!(!load.is_pretty_json); + } + + // ── TextApp navigation ──────────────────────────────────────────────── + + #[test] + fn new_starts_at_top_with_defaults() { + let app = make_app("a\nb\nc\n"); + assert_eq!(app.row_offset(), 0); + assert_eq!(app.col_offset(), 0); + assert!(app.wrap()); + assert!(app.show_line_numbers()); + assert_eq!(app.mode(), &TextMode::Normal); + } + + #[test] + fn j_scrolls_down() { + let mut app = make_app("a\nb\nc\nd\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('j'))); + assert_eq!(app.row_offset(), 1); + } + + #[test] + fn k_scrolls_up() { + let mut app = make_app("a\nb\nc\nd\n"); + app.scroll_down(2); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('k'))); + assert_eq!(app.row_offset(), 1); + } + + #[test] + fn j_clamps_at_last_line() { + let mut app = make_app("a\nb\n"); + for _ in 0..10 { + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('j'))); + } + // 3 lines (a, b, "") — last index is 2. + assert_eq!(app.row_offset(), 2); + } + + #[test] + fn gg_jumps_to_top() { + let mut app = make_app("a\nb\nc\nd\n"); + app.scroll_down(3); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('g'))); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('g'))); + assert_eq!(app.row_offset(), 0); + } + + #[test] + fn capital_g_jumps_to_bottom() { + let mut app = make_app("a\nb\nc\nd\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('G'))); + // 5 lines (a, b, c, d, "") — last index is 4. + assert_eq!(app.row_offset(), 4); + } + + #[test] + fn page_down_scrolls_by_page_amount() { + let many = "x\n".repeat(50); + let mut app = make_app(&many); + dispatch_text_viewer_key(&mut app, &key(KeyCode::PageDown)); + assert_eq!(app.row_offset(), PAGE_SCROLL_LINES); + } + + #[test] + fn h_and_l_only_scroll_when_wrap_off() { + let mut app = make_app("very long line content here\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('l'))); + // wrap is on by default, so 'l' is ignored. + assert_eq!(app.col_offset(), 0); + // Toggle wrap off, then 'l' should advance col_offset. + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('w'))); + assert!(!app.wrap()); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('l'))); + assert_eq!(app.col_offset(), 1); + } + + #[test] + fn toggle_wrap_resets_col_offset() { + let mut app = make_app("line\n"); + app.toggle_wrap(); // off + app.col_offset = 5; + app.toggle_wrap(); // on + assert_eq!(app.col_offset(), 0); + } + + #[test] + fn capital_l_toggles_line_numbers() { + let mut app = make_app("a\n"); + assert!(app.show_line_numbers()); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('L'))); + assert!(!app.show_line_numbers()); + } + + #[test] + fn q_quits() { + let mut app = make_app("a\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('q'))); + assert!(app.should_quit); + } + + // ── Search ─────────────────────────────────────────────────────────── + + #[test] + fn slash_enters_search_mode() { + let mut app = make_app("a\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/'))); + assert_eq!(app.mode(), &TextMode::Search); + assert!(app.is_typing()); + } + + #[test] + fn typing_in_search_populates_matches() { + let mut app = make_app("hello\nworld\nhello again\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/'))); + for c in "hello".chars() { + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char(c))); + } + assert_eq!(app.matches_len(), 2); + } + + #[test] + fn search_is_case_insensitive() { + let mut app = make_app("Hello\nHELLO\nhello\n"); + app.search.query = "hello".to_string(); + app.recompute_matches(); + assert_eq!(app.matches_len(), 3); + } + + #[test] + fn search_finds_overlapping_matches() { + let mut app = make_app("aaaa\n"); + app.search.query = "aa".to_string(); + app.recompute_matches(); + assert_eq!(app.matches_len(), 3); + } + + #[test] + fn enter_confirms_search_and_returns_to_normal() { + let mut app = make_app("a\nfoo\nb\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/'))); + for c in "foo".chars() { + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char(c))); + } + dispatch_text_viewer_key(&mut app, &key(KeyCode::Enter)); + assert_eq!(app.mode(), &TextMode::Normal); + assert_eq!(app.row_offset(), 1); + } + + #[test] + fn esc_cancels_search_and_clears_matches() { + let mut app = make_app("a\nfoo\n"); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/'))); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('f'))); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Esc)); + assert_eq!(app.mode(), &TextMode::Normal); + assert_eq!(app.matches_len(), 0); + } + + #[test] + fn n_cycles_to_next_match() { + let mut app = make_app("foo\nbar\nfoo\nbaz\nfoo\n"); + app.search.query = "foo".to_string(); + app.recompute_matches(); + app.confirm_search(); // jumps to first match (line 0) + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n'))); + assert_eq!(app.row_offset(), 2); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n'))); + assert_eq!(app.row_offset(), 4); + // wraps back + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n'))); + assert_eq!(app.row_offset(), 0); + } + + #[test] + fn capital_n_cycles_backward() { + let mut app = make_app("foo\nbar\nfoo\n"); + app.search.query = "foo".to_string(); + app.recompute_matches(); + app.confirm_search(); + dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('N'))); + assert_eq!(app.row_offset(), 2); // wraps to last + } + + #[test] + fn is_typing_is_true_only_in_search_mode() { + let mut app = make_app("a\n"); + assert!(!app.is_typing()); + app.enter_search(); + assert!(app.is_typing()); + app.exit_search(); + assert!(!app.is_typing()); + } + + // ── highlight_spans ───────────────────────────────────────────────── + + #[test] + fn highlight_spans_returns_single_span_when_no_query() { + let spans = highlight_spans("hello", "", theme()); + assert_eq!(spans.len(), 1); + } + + #[test] + fn highlight_spans_splits_on_match() { + let spans = highlight_spans("hello world hello", "hello", theme()); + // [hello][ world ][hello] — three spans, two matches in the middle of ranges. + assert!(spans.len() >= 3); + } +} diff --git a/src/ui.rs b/src/ui.rs index 5cf3483..75facea 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2122,7 +2122,7 @@ mod null_render_tests { .filter(|&(x, y)| { buffer .cell(Position::new(x, y)) - .map_or(false, |c| c.symbol() == NULL_GLYPH) + .is_some_and(|c| c.symbol() == NULL_GLYPH) }) .count(); assert_eq!(null_count, 1, "one numeric null should render as one ∅"); diff --git a/tests/fixtures/sample.json b/tests/fixtures/sample.json new file mode 100644 index 0000000..b3e5d53 --- /dev/null +++ b/tests/fixtures/sample.json @@ -0,0 +1 @@ +{"version":"1.0","name":"datasight","tags":["tui","csv","parquet"],"nested":{"flag":true,"count":42}} diff --git a/tests/fixtures/sample.txt b/tests/fixtures/sample.txt new file mode 100644 index 0000000..b41a09a --- /dev/null +++ b/tests/fixtures/sample.txt @@ -0,0 +1,19 @@ +Datasight text viewer fixture. +This file is used by the QA script and the text_viewer tests. + +Section one — overview. +The browser sub-command lists files of every kind in a sidebar. +Plain-text files like this one open in the text viewer. +Press / to start a search, type a query, then Enter to jump. +Press n / N to cycle through hits. + +Section two — searchable token: needle. +The token "needle" appears here so qa.sh can verify the search path. +needle (lowercase) +NEEDLE (uppercase, case-insensitive search should still hit) +And one more needle for good measure. + +Section three — long line check. +This-paragraph-has-no-spaces-and-is-deliberately-long-so-that-toggling-word-wrap-and-horizontal-scroll-can-be-exercised-during-the-QA-sweep. + +End of fixture. diff --git a/tests/fixtures/sample_binary.png b/tests/fixtures/sample_binary.png new file mode 100644 index 0000000000000000000000000000000000000000..be9a6b83feede245aaea43e637251c0d556b8ff9 GIT binary patch literal 30 kcmeAS@N?(olHy_jf~3s6#G*=tw9JZ<(xTLc2@5s=0CfBbwg3PC literal 0 HcmV?d00001