Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
95 changes: 92 additions & 3 deletions qa.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 "════════════════════════════════════════"
Expand Down
42 changes: 37 additions & 5 deletions src/browser/app.rs
Original file line number Diff line number Diff line change
@@ -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<App>),
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<dyn FileBrowser>,
pub entries: Vec<Entry>,
pub cursor: usize,
pub cwd: String,
pub viewer: Option<App>,
pub viewer: Option<Viewer>,
pub browser_visible: bool,
pub focus: Focus,
pub status: Option<String>,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<Entry>,
Expand Down Expand Up @@ -145,17 +177,17 @@ 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,
}
}

fn dir_entry(name: &str) -> Entry {
Entry {
name: name.to_string(),
path: format!("/test/{}", name),
is_dir: true,
kind: EntryKind::Dir,
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/browser/azure.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
}

Expand Down
102 changes: 84 additions & 18 deletions src/browser/events.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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 => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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<u8> {
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));
}
}
}
Loading
Loading