use anyhow::Result;
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
};
use ratatui::{DefaultTerminal, Frame};
use std::path::PathBuf;
use crate::formats::{self, Decoded, FormatKind};
use crate::hexedit::{self, HexEdit};
use crate::mru::{self, Entry};
use crate::overlay::{HelpCtx, Overlays};
use crate::rkyv_inspect::RkyvStore;
use crate::sqlite::{RowsView, Sort, SqliteStore};
use crate::store::{Kind, Store};
use crate::theme::{Theme, ThemeName};
const PAGE: i64 = 500;
const PAGE_GRACE: std::time::Duration = std::time::Duration::from_millis(30);
const MIN_STRING: usize = 4;
const FREQUENCY_ROWS: i64 = 20;
const TICK: std::time::Duration = std::time::Duration::from_millis(250);
const DOT_HELP: &str = "\
.tables list the tables
.schema [TABLE] CREATE statements
.indexes [TABLE] indexes on a table, with their columns
.dump [TABLE] schema and data as replayable SQL
.databases attached databases
.attach FILE ALIAS attach another database
.detach ALIAS detach it again
.mode MODE list csv tsv markdown line insert json
.headers on|off column names in redirected output
.output FILE send results to a file
.once FILE send the next result to a file
.timer on|off report how long a statement took
.eqp on|off print the query plan before each statement
.import FILE TABLE load a CSV or TSV
.read FILE run the statements in a file
.backup FILE copy the database with VACUUM INTO
.expert index advice for the statement just run
.recover [FILE] salvage rows page by page into a script
.vacuum .analyze .reindex maintenance
.quit leave zdbview";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum OutputMode {
List,
Csv,
Tsv,
Markdown,
Line,
Insert,
Json,
}
impl OutputMode {
fn parse(name: &str) -> Option<Self> {
Some(match name.to_lowercase().as_str() {
"list" | "column" | "box" | "table" => OutputMode::List,
"csv" => OutputMode::Csv,
"tsv" | "tabs" => OutputMode::Tsv,
"markdown" => OutputMode::Markdown,
"line" => OutputMode::Line,
"insert" => OutputMode::Insert,
"json" => OutputMode::Json,
_ => return None,
})
}
fn label(self) -> &'static str {
match self {
OutputMode::List => "list",
OutputMode::Csv => "csv",
OutputMode::Tsv => "tsv",
OutputMode::Markdown => "markdown",
OutputMode::Line => "line",
OutputMode::Insert => "insert",
OutputMode::Json => "json",
}
}
fn render(
self,
columns: &[String],
rows: &[Vec<String>],
literals: &[Vec<String>],
table: &str,
headers: bool,
) -> String {
use crate::export::*;
match self {
OutputMode::Csv => {
let out = rows_to_csv(columns, rows);
if headers {
out
} else {
out.split_once('\n')
.map(|(_, r)| r.to_string())
.unwrap_or(out)
}
}
OutputMode::Tsv => {
let out = rows_to_tsv(columns, rows);
if headers {
out
} else {
out.split_once('\n')
.map(|(_, r)| r.to_string())
.unwrap_or(out)
}
}
OutputMode::Markdown => rows_to_markdown(columns, rows),
OutputMode::Line => rows_to_lines(columns, rows),
OutputMode::Insert => rows_to_inserts_exact(table, columns, literals),
OutputMode::Json => rows_to_json(columns, rows),
OutputMode::List => rows_to_csv(columns, rows),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum HexTarget {
Record,
Cell {
table: String,
key: crate::sqlite::RowKey,
column: String,
},
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum Focus {
Left,
Right,
}
enum Mode {
Normal,
EditCell(String),
Search(String),
AddRecord(String),
RenameRecord(String),
ConfirmDelete,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum RkyvView {
Records,
Info,
Strings,
Hex,
}
#[derive(Debug, Clone, Copy)]
struct SearchOrigin {
table_idx: usize,
page_offset: i64,
row_idx: usize,
record_idx: usize,
string_idx: usize,
hex_row: usize,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Outcome {
Quit,
Reopen,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum Screen {
Main,
Detail,
HexEdit,
Top,
Sql,
DbInfo,
Stats,
Frames,
Schema,
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum ValueRender {
Auto,
Hex,
Text,
Disasm,
}
impl ValueRender {
fn label(self) -> &'static str {
match self {
ValueRender::Auto => "auto",
ValueRender::Hex => "hex",
ValueRender::Text => "text",
ValueRender::Disasm => "disasm",
}
}
fn next(self) -> Self {
match self {
ValueRender::Auto => ValueRender::Hex,
ValueRender::Hex => ValueRender::Text,
ValueRender::Text => ValueRender::Disasm,
ValueRender::Disasm => ValueRender::Auto,
}
}
}
pub struct App {
store: Store,
focus: Focus,
mode: Mode,
status: String,
quit: bool,
table_idx: usize,
rows: Option<RowsView>,
page_offset: i64,
row_idx: usize,
col_idx: usize,
rkyv_view: RkyvView,
strings: Vec<crate::rkyv_inspect::StringHit>,
string_idx: usize,
hex_row: usize,
decoded: Option<Decoded>,
record_idx: usize,
pending_g: bool,
search: String,
screen: Screen,
value_render: ValueRender,
detail_value: Vec<u8>,
detail_scroll: usize,
schema_scroll: usize,
schema: Vec<(String, String, String)>,
click_left: Rect,
click_right: Rect,
click_records: Rect,
off_left: usize,
off_right: usize,
off_records: usize,
input_cursor: usize,
hex: Option<HexEdit>,
hex_area: Rect,
top: Option<crate::monitor::Monitor>,
top_area: Rect,
sql: Option<crate::sqledit::SqlEdit>,
walk: Option<crate::frames::FrameView>,
hex_from: Screen,
hex_target: HexTarget,
stats_pending: Option<(String, std::sync::mpsc::Receiver<StatsResult>)>,
stats: Vec<crate::sqlite::ColumnStat>,
stats_idx: usize,
stats_freq: Option<(String, Vec<(String, i64)>)>,
dbinfo: Vec<(String, String)>,
dbinfo_checks: Vec<String>,
dbinfo_scroll: usize,
sql_area: Rect,
sql_mode: OutputMode,
sql_out: Option<(PathBuf, bool)>,
sql_headers: bool,
sql_timer: bool,
sql_eqp: bool,
reopen: bool,
open_next: Option<PathBuf>,
search_origin: Option<SearchOrigin>,
filter: String,
strings_truncated: bool,
strings_scanned: usize,
decoding: Option<std::sync::mpsc::Receiver<Option<Decoded>>>,
page_rows: usize,
sort: Option<Sort>,
ov: Overlays,
engine: Option<crate::query::Engine>,
page_generation: u64,
pending_bottom: bool,
loaded_offset: i64,
exact_total: Option<(String, String, i64)>,
}
impl App {
pub fn with_theme(store: Store, theme: Theme) -> Self {
let mut app = App {
store,
focus: Focus::Left,
mode: Mode::Normal,
status: String::new(),
quit: false,
table_idx: 0,
rows: None,
page_offset: 0,
row_idx: 0,
col_idx: 0,
rkyv_view: RkyvView::Info,
strings: Vec::new(),
string_idx: 0,
hex_row: 0,
decoded: None,
record_idx: 0,
pending_g: false,
search: String::new(),
screen: Screen::Main,
value_render: ValueRender::Auto,
detail_value: Vec::new(),
detail_scroll: 0,
schema_scroll: 0,
schema: Vec::new(),
click_left: Rect::ZERO,
click_right: Rect::ZERO,
click_records: Rect::ZERO,
off_left: 0,
off_right: 0,
off_records: 0,
input_cursor: 0,
sort: None,
hex: None,
hex_area: Rect::ZERO,
top: None,
top_area: Rect::ZERO,
sql: None,
walk: None,
hex_from: Screen::Main,
hex_target: HexTarget::Record,
stats_pending: None,
stats: Vec::new(),
stats_idx: 0,
stats_freq: None,
dbinfo: Vec::new(),
dbinfo_checks: Vec::new(),
dbinfo_scroll: 0,
sql_area: Rect::ZERO,
sql_mode: OutputMode::List,
sql_out: None,
sql_headers: true,
sql_timer: true,
sql_eqp: false,
reopen: false,
open_next: None,
search_origin: None,
filter: String::new(),
strings_truncated: false,
strings_scanned: 0,
decoding: None,
page_rows: 10,
ov: Overlays::new(theme),
engine: None,
page_generation: 0,
pending_bottom: false,
loaded_offset: 0,
exact_total: None,
};
if let Store::Sqlite(s) = &app.store {
app.engine = Some(crate::query::Engine::new(&s.path));
}
app.init();
app
}
fn back_to_files(&mut self) {
self.reopen = true;
self.quit = true;
}
pub fn open_next(&mut self) -> Option<PathBuf> {
self.open_next.take()
}
pub fn theme(&self) -> Theme {
self.ov.theme
}
fn screen_owns_keys(&self) -> bool {
matches!(self.screen, Screen::HexEdit | Screen::Sql)
}
fn help_ctx(&self) -> HelpCtx {
if self.screen == Screen::HexEdit {
return HelpCtx::HexEdit;
}
if self.screen == Screen::Top {
return HelpCtx::Top;
}
if self.screen == Screen::Sql {
return HelpCtx::Sql;
}
if self.screen == Screen::DbInfo {
return HelpCtx::DbInfo;
}
if self.screen == Screen::Stats {
return HelpCtx::Stats;
}
if self.screen == Screen::Frames {
return HelpCtx::Frames;
}
match &self.store {
Store::Sqlite(_) => HelpCtx::Sqlite,
Store::Rkyv(_) => HelpCtx::Rkyv,
}
}
fn notify(&mut self, msg: impl Into<String>) {
let msg = msg.into();
self.ov.toast(msg.clone());
self.status = msg;
}
fn init(&mut self) {
match &self.store {
Store::Sqlite(s) => {
if !s.tables.is_empty() {
self.load_table();
}
self.status = "j/k ←/→ move · Tab focus · / filter · ^f/^b page · e edit · a add · d delete · : SQL · s sort · c scheme · o/Esc files · h help · q quit".into();
}
Store::Rkyv(r) => {
let s = r.strings(MIN_STRING);
self.strings_truncated = s.truncated;
self.strings_scanned = s.scanned;
self.strings = s.hits;
if r.bytes.len() > DECODE_INLINE_MAX {
self.decoding = Some(spawn_decode(r.bytes.clone()));
self.rkyv_view = RkyvView::Info;
self.status = format!(
"decoding {} in the background · 1 Info · 2 Strings · 3 Hex · o/Esc files · q quit",
human_size(r.bytes.len() as u64)
);
return;
}
self.decoded = formats::try_decode(&r.bytes);
if let Some(d) = &self.decoded {
self.rkyv_view = RkyvView::Records;
self.status = format!(
"{} · {} records · Enter detail · a add e hex-edit r rename d delete · / filter · 0/1/2/3 views · c scheme · o/Esc files · h help · q quit",
d.format,
d.records.len()
);
} else {
self.status = "1 Info · 2 Strings · 3 Hex · j/k scroll · / filter · c scheme · o/Esc files · h help · q quit (rkyv: unrecognized)".into();
}
}
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Outcome> {
while !self.quit {
self.poll_decode();
self.poll_stats();
self.poll_query();
if let Some(w) = self.top.as_mut() {
w.tick();
}
terminal.draw(|f| self.render(f))?;
if !event::poll(TICK)? {
self.ov.expire_toast();
continue;
}
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key),
Event::Mouse(m) => self.on_mouse(m),
_ => {}
}
self.ov.expire_toast();
}
Ok(if self.reopen {
Outcome::Reopen
} else {
Outcome::Quit
})
}
fn on_key(&mut self, key: KeyEvent) {
let code = key.code;
if self.ov.active() {
self.ov.on_key(code);
return;
}
enum Modal {
Edit(String),
Search(String),
Add(String),
Rename(String),
Confirm,
None,
}
let modal = match &self.mode {
Mode::EditCell(buf) => Modal::Edit(buf.clone()),
Mode::Search(buf) => Modal::Search(buf.clone()),
Mode::AddRecord(buf) => Modal::Add(buf.clone()),
Mode::RenameRecord(buf) => Modal::Rename(buf.clone()),
Mode::ConfirmDelete => Modal::Confirm,
Mode::Normal => Modal::None,
};
match modal {
Modal::Edit(buf) => {
return self.key_input(key, buf, Mode::EditCell, App::commit_edit_cell)
}
Modal::Search(buf) => {
match code {
KeyCode::Up => {
self.move_selection(-1);
return;
}
KeyCode::Down => {
self.move_selection(1);
return;
}
KeyCode::PageUp => {
let step = self.page_step() as isize;
self.move_selection(-step);
return;
}
KeyCode::PageDown => {
let step = self.page_step() as isize;
self.move_selection(step);
return;
}
_ => {}
}
return self.key_input(key, buf, Mode::Search, App::commit_search);
}
Modal::Add(buf) => {
return self.key_input(key, buf, Mode::AddRecord, App::commit_add_record)
}
Modal::Rename(buf) => {
return self.key_input(key, buf, Mode::RenameRecord, App::commit_rename_record)
}
Modal::Confirm => return self.key_confirm_delete(code),
Modal::None => {}
}
if !self.screen_owns_keys() && self.ov.on_key(code) {
return;
}
if code == KeyCode::Char('w') && !self.screen_owns_keys() && self.screen != Screen::Top {
self.open_top();
return;
}
if code == KeyCode::Char('o') && !self.screen_owns_keys() {
self.back_to_files();
return;
}
match self.screen {
Screen::HexEdit => return self.key_hex(key),
Screen::Top => return self.key_top(code),
Screen::Sql => return self.key_sql(key),
Screen::DbInfo => return self.key_dbinfo(code),
Screen::Stats => return self.key_stats(code),
Screen::Frames => return self.key_frames(code),
Screen::Detail => return self.key_detail(code),
Screen::Schema => return self.key_schema(code),
Screen::Main => {}
}
match &self.store {
Store::Sqlite(_) => self.key_sqlite(key),
Store::Rkyv(_) => self.key_rkyv(key),
}
}
fn on_mouse(&mut self, m: MouseEvent) {
if self.ov.on_mouse(m) {
return;
}
if self.screen == Screen::Top {
let area = self.top_area;
if let Some(mon) = self.top.as_mut() {
mon.on_mouse(m, area);
if let Some(note) = mon.note.take() {
self.notify(note);
}
}
return;
}
if self.screen == Screen::HexEdit {
let area = self.hex_area;
if let Some(ed) = self.hex.as_mut() {
ed.on_mouse(m, area);
}
return;
}
match m.kind {
MouseEventKind::ScrollDown => self.scroll_select(true),
MouseEventKind::ScrollUp => self.scroll_select(false),
MouseEventKind::Down(MouseButton::Left) => self.click_at(m.column, m.row, false),
MouseEventKind::Down(MouseButton::Right) => self.click_at(m.column, m.row, true),
_ => {}
}
}
fn scroll_select(&mut self, down: bool) {
if !matches!(self.mode, Mode::Normal) {
return;
}
let code = if down { KeyCode::Down } else { KeyCode::Up };
self.on_key(KeyEvent::new(code, KeyModifiers::empty()));
}
fn click_at(&mut self, col: u16, row: u16, right: bool) {
if !matches!(self.mode, Mode::Normal) || self.screen != Screen::Main {
return;
}
match &self.store {
Store::Sqlite(_) => {
if hit(self.click_left, col, row) {
self.focus = Focus::Left;
let idx = self.off_left + row.saturating_sub(self.click_left.y + 1) as usize;
self.select_table(idx);
} else if hit(self.click_right, col, row) {
self.focus = Focus::Right;
let idx = self.off_right + row.saturating_sub(self.click_right.y + 2) as usize;
let n = self.rows.as_ref().map(|r| r.rows.len()).unwrap_or(0);
if idx < n {
self.row_idx = idx;
if right {
self.enter_detail();
}
}
}
}
Store::Rkyv(_) => {
if self.rkyv_view == RkyvView::Records && hit(self.click_records, col, row) {
let idx =
self.off_records + row.saturating_sub(self.click_records.y + 1) as usize;
let n = self.decoded.as_ref().map(|d| d.records.len()).unwrap_or(0);
if idx < n {
self.record_idx = idx;
if right {
self.enter_detail();
}
}
}
}
}
}
fn key_detail(&mut self, code: KeyCode) {
let max_scroll = self.detail_value.len() / 16;
match code {
KeyCode::Char('q') => self.quit = true,
KeyCode::Esc | KeyCode::Enter => {
self.screen = Screen::Main;
self.detail_scroll = 0;
}
KeyCode::Char('v') => self.value_render = self.value_render.next(),
KeyCode::Char('y') => self.copy_detail_value(),
KeyCode::Char('e') => self.edit_current_value(),
KeyCode::Char('E') => self.edit_cell_as_bytes(),
KeyCode::Right => {
let n = self
.rows
.as_ref()
.map(|r| r.columns.len())
.unwrap_or(0)
.saturating_sub(1);
if self.col_idx < n {
self.col_idx += 1;
self.detail_scroll = 0;
self.load_detail_value(false);
}
}
KeyCode::Left => {
if self.col_idx > 0 {
self.col_idx -= 1;
self.detail_scroll = 0;
self.load_detail_value(false);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.detail_scroll < max_scroll {
self.detail_scroll += 1;
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.detail_scroll = self.detail_scroll.saturating_sub(1)
}
KeyCode::Char('g') => self.detail_scroll = 0,
KeyCode::Char('G') => self.detail_scroll = max_scroll,
KeyCode::PageDown => {
self.detail_scroll = (self.detail_scroll + self.page_step()).min(max_scroll)
}
KeyCode::PageUp => {
self.detail_scroll = self.detail_scroll.saturating_sub(self.page_step())
}
_ => {}
}
}
fn key_schema(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') => self.quit = true,
KeyCode::Esc | KeyCode::Char('S') => {
self.screen = Screen::Main;
self.schema_scroll = 0;
}
KeyCode::Down | KeyCode::Char('j') => self.schema_scroll += 1,
KeyCode::Up | KeyCode::Char('k') => {
self.schema_scroll = self.schema_scroll.saturating_sub(1)
}
KeyCode::Char('g') => self.schema_scroll = 0,
KeyCode::PageDown => self.schema_scroll += self.page_step(),
KeyCode::PageUp => {
self.schema_scroll = self.schema_scroll.saturating_sub(self.page_step())
}
_ => {}
}
}
fn enter_detail(&mut self) {
self.detail_scroll = 0;
self.load_detail_value(true);
}
fn load_detail_value(&mut self, enter: bool) {
match &self.store {
Store::Sqlite(_) => {
let bytes = match (self.current_table(), self.current_rowid()) {
(Some(t), Some(rid)) => {
let col = self
.rows
.as_ref()
.and_then(|r| r.columns.get(self.col_idx).cloned())
.unwrap_or_default();
self.sqlite()
.and_then(|s| {
s.cell_bytes_keyed(&t, &crate::sqlite::RowKey::Rowid(rid), &col)
.ok()
})
.unwrap_or_default()
}
_ => self
.rows
.as_ref()
.and_then(|r| r.rows.get(self.row_idx))
.and_then(|row| row.get(self.col_idx))
.map(|s| s.clone().into_bytes())
.unwrap_or_default(),
};
self.detail_value = bytes;
if enter {
self.screen = Screen::Detail;
}
}
Store::Rkyv(_) => {
if let Some(rec) = self
.decoded
.as_ref()
.and_then(|d| d.records.get(self.record_idx))
{
self.detail_value = rec.value.clone();
if enter {
self.screen = Screen::Detail;
}
}
}
}
}
fn open_schema(&mut self) {
if let Some(s) = self.sqlite() {
self.schema = s.schema().unwrap_or_default();
self.schema_scroll = 0;
self.screen = Screen::Schema;
}
}
fn copy_detail_value(&mut self) {
let text = match self.value_render {
ValueRender::Text | ValueRender::Auto if looks_textual(&self.detail_value) => {
String::from_utf8_lossy(&self.detail_value).into_owned()
}
_ => hex_string(&self.detail_value),
};
let ok = crate::clipboard::copy(&text);
self.notify(if ok {
format!("copied {} bytes to clipboard", self.detail_value.len())
} else {
"clipboard unavailable (no tty)".into()
});
}
fn export_current(&mut self) {
match &self.store {
Store::Sqlite(_) => self.export_sqlite(),
Store::Rkyv(_) => self.export_rkyv(),
}
}
fn export_sqlite(&mut self) {
let table = match self.current_table() {
Some(t) => t,
None => return,
};
let total = self
.known_total()
.or_else(|| self.rows.as_ref().map(|r| r.total))
.unwrap_or(0);
let view = match self.sqlite().unwrap().rows(&crate::sqlite::PageQuery {
table: &table,
limit: total.max(1),
offset: 0,
sort: self.sort.as_ref(),
filter: &self.filter,
hint: None,
known_total: None,
}) {
Ok(v) => v,
Err(e) => {
self.notify(format!("export failed: {}", e));
return;
}
};
let csv = crate::export::rows_to_csv(&view.columns, &view.rows);
let path = format!("{}.csv", sanitize(&table));
match std::fs::write(&path, csv) {
Ok(()) => self.status = format!("exported {} rows → {}", view.rows.len(), path),
Err(e) => self.status = format!("write failed: {}", e),
}
}
fn export_rkyv(&mut self) {
let d = match &self.decoded {
Some(d) => d,
None => {
self.notify("nothing to export (unrecognized archive)");
return;
}
};
let recs: Vec<crate::export::RecordExport> = d
.records
.iter()
.map(|r| crate::export::RecordExport {
key: &r.key,
fields: &r.fields,
value: &r.value,
})
.collect();
let json = crate::export::records_to_json(&recs);
let base = match &self.store {
Store::Rkyv(r) => r
.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("records")
.to_string(),
_ => "records".into(),
};
let path = format!("{}.records.json", sanitize(&base));
match std::fs::write(&path, json) {
Ok(()) => self.status = format!("exported {} records → {}", d.records.len(), path),
Err(e) => self.status = format!("write failed: {}", e),
}
}
fn key_sqlite(&mut self, key: KeyEvent) {
let code = key.code;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl {
match code {
KeyCode::Char('f') => self.page_sqlite(true),
KeyCode::Char('b') => self.page_sqlite(false),
_ => {}
}
return;
}
if code == KeyCode::Char('g') {
if self.pending_g {
self.pending_g = false;
self.goto_top();
} else {
self.pending_g = true;
}
return;
}
self.pending_g = false;
match code {
KeyCode::Char('G') => self.goto_bottom(),
KeyCode::Char('/') => self.open_modal(Mode::Search(String::new())),
KeyCode::Char('n') => self.search_next(true),
KeyCode::Char('N') => self.search_next(false),
KeyCode::Char('q') => self.quit = true,
KeyCode::Esc => self.back_to_files(),
KeyCode::Tab => {
self.focus = if self.focus == Focus::Left {
Focus::Right
} else {
Focus::Left
};
}
KeyCode::Up | KeyCode::Char('k') => match self.focus {
Focus::Left => {
let visible = self.visible_tables();
if let Some(i) = Self::step_visible(&visible, self.table_idx, -1) {
self.select_table(i);
}
}
Focus::Right => self.row_idx = self.row_idx.saturating_sub(1),
},
KeyCode::Down | KeyCode::Char('j') => match self.focus {
Focus::Left => {
let visible = self.visible_tables();
if let Some(i) = Self::step_visible(&visible, self.table_idx, 1) {
self.select_table(i);
}
}
Focus::Right => {
if let Some(r) = &self.rows {
if self.row_idx + 1 < r.rows.len() {
self.row_idx += 1;
}
}
}
},
KeyCode::Left => {
if self.focus == Focus::Right {
self.col_idx = self.col_idx.saturating_sub(1);
}
}
KeyCode::Right => {
if self.focus == Focus::Right {
if let Some(r) = &self.rows {
if self.col_idx + 1 < r.columns.len() {
self.col_idx += 1;
}
}
}
}
KeyCode::Enter => match self.focus {
Focus::Left => self.focus = Focus::Right,
Focus::Right => self.enter_detail(),
},
KeyCode::PageDown => self.page_sqlite(true),
KeyCode::PageUp => self.page_sqlite(false),
KeyCode::Char('e') => self.begin_edit_cell(),
KeyCode::Char('E') => self.edit_cell_as_bytes(),
KeyCode::Char('a') => self.insert_row(),
KeyCode::Char('d') => {
if self.focus == Focus::Right && self.current_rowid().is_some() {
self.mode = Mode::ConfirmDelete;
}
}
KeyCode::Char('S') => self.open_schema(),
KeyCode::Char('D') => self.open_dbinfo(),
KeyCode::Char('A') => self.open_stats(),
KeyCode::Char('Y') => self.copy_row_as_insert(),
KeyCode::Char('F') => self.follow_foreign_key(),
KeyCode::Char('s') => self.sort_by_current_column(),
KeyCode::Char('<') => self.sort_shift_column(false),
KeyCode::Char('>') => self.sort_shift_column(true),
KeyCode::Char('x') => self.export_current(),
KeyCode::Char('y') => self.copy_sqlite_cell(),
KeyCode::Char(':') => self.open_sql(),
_ => {}
}
}
fn copy_rkyv_key(&mut self) {
let key = self
.decoded
.as_ref()
.and_then(|d| d.records.get(self.record_idx))
.map(|r| r.key.clone());
if let Some(k) = key {
let ok = crate::clipboard::copy(&k);
self.status = if ok {
"copied key to clipboard".into()
} else {
"clipboard unavailable (no tty)".into()
};
}
}
fn copy_sqlite_cell(&mut self) {
let cell = self
.rows
.as_ref()
.and_then(|r| r.rows.get(self.row_idx))
.and_then(|row| row.get(self.col_idx))
.cloned()
.unwrap_or_default();
let ok = crate::clipboard::copy(&cell);
self.notify(if ok {
"copied cell to clipboard"
} else {
"clipboard unavailable (no tty)"
});
}
fn key_rkyv(&mut self, key: KeyEvent) {
let code = key.code;
if code == KeyCode::Char('g') {
if self.pending_g {
self.pending_g = false;
self.rkyv_goto_top();
} else {
self.pending_g = true;
}
return;
}
self.pending_g = false;
match code {
KeyCode::Char('G') => self.rkyv_goto_bottom(),
KeyCode::Char('/') => self.open_modal(Mode::Search(String::new())),
KeyCode::Char('n') => self.search_next(true),
KeyCode::Char('N') => self.search_next(false),
KeyCode::Char('q') => self.quit = true,
KeyCode::Esc => self.back_to_files(),
KeyCode::Char('0') => {
if self.decoded.is_some() {
self.rkyv_view = RkyvView::Records;
} else if self.decoding.is_some() {
self.notify("still decoding — Records will open when it lands");
}
}
KeyCode::Char('1') => self.rkyv_view = RkyvView::Info,
KeyCode::Char('2') => self.rkyv_view = RkyvView::Strings,
KeyCode::Char('3') => self.rkyv_view = RkyvView::Hex,
KeyCode::Up | KeyCode::Char('k') => self.move_rkyv(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_rkyv(1),
KeyCode::PageDown => self.page_rkyv(true),
KeyCode::PageUp => self.page_rkyv(false),
KeyCode::Enter => {
if self.rkyv_view == RkyvView::Records {
self.enter_detail();
}
}
KeyCode::Char('d') => {
if self.rkyv_view == RkyvView::Records && self.has_current_record() {
self.mode = Mode::ConfirmDelete;
}
}
KeyCode::Char('a') => {
if self.rkyv_view == RkyvView::Records && self.decoded.is_some() {
self.open_modal(Mode::AddRecord(String::new()));
}
}
KeyCode::Char('e') => {
if self.rkyv_view == RkyvView::Records && self.has_current_record() {
self.open_hex_editor();
}
}
KeyCode::Char('r') => {
let renamable = matches!(
self.decoded.as_ref().map(|d| d.kind),
Some(
FormatKind::Script
| FormatKind::Stryke
| FormatKind::Autoload
| FormatKind::Elisp
)
);
if self.rkyv_view == RkyvView::Records && self.has_current_record() && renamable {
let key = self
.decoded
.as_ref()
.and_then(|d| d.records.get(self.record_idx))
.map(|r| r.key.clone())
.unwrap_or_default();
self.open_modal(Mode::RenameRecord(key));
}
}
KeyCode::Char('x') => self.export_current(),
KeyCode::Char('y') => self.copy_rkyv_key(),
_ => {}
}
}
fn key_input(
&mut self,
key: KeyEvent,
mut buf: String,
mk: fn(String) -> Mode,
commit: fn(&mut App, &str),
) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let mut cur = self.input_cursor.min(buf.len());
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
if let Some(o) = self.search_origin.take() {
self.set_filter(String::new());
self.restore_position(o);
self.status.clear();
}
return;
}
KeyCode::Enter => {
self.mode = Mode::Normal;
if self.search_origin.take().is_some() {
return;
}
commit(self, &buf);
return;
}
KeyCode::Left => cur = input_left(&buf, cur),
KeyCode::Right => cur = input_right(&buf, cur),
KeyCode::Home => cur = 0,
KeyCode::End => cur = buf.len(),
KeyCode::Char('a') if ctrl => cur = 0,
KeyCode::Char('e') if ctrl => cur = buf.len(),
KeyCode::Char('b') if ctrl => cur = input_left(&buf, cur),
KeyCode::Char('f') if ctrl => cur = input_right(&buf, cur),
KeyCode::Char('w') if ctrl => cur = input_delete_word(&mut buf, cur),
KeyCode::Char('u') if ctrl => {
buf.drain(..cur);
cur = 0;
}
KeyCode::Char('k') if ctrl => buf.truncate(cur),
KeyCode::Backspace => {
if cur > 0 {
let p = input_left(&buf, cur);
buf.drain(p..cur);
cur = p;
}
}
KeyCode::Delete => {
if cur < buf.len() {
let n = input_right(&buf, cur);
buf.drain(cur..n);
}
}
KeyCode::Char(c) => {
buf.insert(cur, c);
cur += c.len_utf8();
}
_ => {}
}
self.input_cursor = cur;
self.mode = mk(buf);
if let Mode::Search(pattern) = &self.mode {
let pattern = pattern.clone();
self.filter_preview(&pattern);
}
}
fn snapshot_position(&self) -> SearchOrigin {
SearchOrigin {
table_idx: self.table_idx,
page_offset: self.page_offset,
row_idx: self.row_idx,
record_idx: self.record_idx,
string_idx: self.string_idx,
hex_row: self.hex_row,
}
}
fn restore_position(&mut self, o: SearchOrigin) {
self.table_idx = o.table_idx;
self.record_idx = o.record_idx;
self.string_idx = o.string_idx;
self.hex_row = o.hex_row;
self.row_idx = o.row_idx;
if self.page_offset != o.page_offset {
self.page_offset = o.page_offset;
self.load_table();
self.row_idx = o.row_idx;
}
}
fn filter_preview(&mut self, pattern: &str) {
self.set_filter(pattern.to_string());
}
fn set_filter(&mut self, pattern: String) {
if self.filter == pattern {
return;
}
self.filter = pattern;
match &self.store {
Store::Sqlite(_) => {
if self.focus == Focus::Left {
let visible = self.visible_tables();
if !visible.contains(&self.table_idx) {
if let Some(&first) = visible.first() {
self.table_idx = first;
}
}
}
self.page_offset = 0;
self.row_idx = 0;
self.load_table();
}
Store::Rkyv(_) => {
self.record_idx = self.first_visible_record().unwrap_or(0);
self.string_idx = self.first_visible_string().unwrap_or(0);
}
}
self.status = if self.filter.is_empty() {
String::new()
} else {
let n = self.visible_count();
format!(
"/{} ({} match{})",
self.filter,
n,
if n == 1 { "" } else { "es" }
)
};
}
fn passes(&self, hay: &str) -> bool {
self.filter.is_empty() || hay.to_lowercase().contains(&self.filter.to_lowercase())
}
fn visible_records(&self) -> Vec<usize> {
match &self.decoded {
Some(d) => d
.records
.iter()
.enumerate()
.filter(|(_, r)| self.passes(&r.key))
.map(|(i, _)| i)
.collect(),
None => Vec::new(),
}
}
fn visible_strings(&self) -> Vec<usize> {
self.strings
.iter()
.enumerate()
.filter(|(_, s)| self.passes(&s.text))
.map(|(i, _)| i)
.collect()
}
fn visible_tables(&self) -> Vec<usize> {
let tables = self.sqlite().map(|s| s.tables.clone()).unwrap_or_default();
tables
.iter()
.enumerate()
.filter(|(_, t)| self.passes(t))
.map(|(i, _)| i)
.collect()
}
fn first_visible_record(&self) -> Option<usize> {
self.visible_records().first().copied()
}
fn first_visible_string(&self) -> Option<usize> {
self.visible_strings().first().copied()
}
fn visible_count(&self) -> usize {
match &self.store {
Store::Sqlite(_) => match self.focus {
Focus::Left => self.visible_tables().len(),
Focus::Right => self.rows.as_ref().map(|r| r.total as usize).unwrap_or(0),
},
Store::Rkyv(_) => match self.rkyv_view {
RkyvView::Records => self.visible_records().len(),
RkyvView::Strings => self.visible_strings().len(),
_ => 0,
},
}
}
fn open_modal(&mut self, mode: Mode) {
if matches!(mode, Mode::Search(_)) {
self.search_origin = Some(self.snapshot_position());
}
self.input_cursor = match &mode {
Mode::EditCell(s) | Mode::Search(s) | Mode::AddRecord(s) | Mode::RenameRecord(s) => {
s.len()
}
_ => 0,
};
self.mode = mode;
}
fn commit_search(&mut self, pattern: &str) {
self.search = pattern.to_string();
self.search_next(true);
}
fn key_confirm_delete(&mut self, code: KeyCode) {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
match &self.store {
Store::Sqlite(_) => self.delete_current_row(),
Store::Rkyv(_) => self.delete_current_record(),
}
self.mode = Mode::Normal;
}
_ => self.mode = Mode::Normal,
}
}
fn has_current_record(&self) -> bool {
self.decoded
.as_ref()
.is_some_and(|d| self.record_idx < d.records.len())
}
fn rkyv_kind_bytes(&self) -> Option<(FormatKind, std::sync::Arc<[u8]>)> {
let kind = self.decoded.as_ref().map(|d| d.kind)?;
match &self.store {
Store::Rkyv(r) => Some((kind, r.bytes.clone())),
_ => None,
}
}
fn rkyv_ctx(&self) -> Option<(String, String, FormatKind, std::sync::Arc<[u8]>)> {
let (key, del_key, kind) = self.decoded.as_ref().and_then(|d| {
d.records
.get(self.record_idx)
.map(|r| (r.key.clone(), r.del_key.clone(), d.kind))
})?;
match &self.store {
Store::Rkyv(r) => Some((key, del_key, kind, r.bytes.clone())),
_ => None,
}
}
fn rkyv_apply(&mut self, result: Result<Vec<u8>, String>, ok_msg: String) {
let new_bytes = match result {
Ok(b) => b,
Err(e) => {
self.notify(format!("failed: {}", e));
return;
}
};
let path = match &self.store {
Store::Rkyv(r) => r.path.clone(),
_ => return,
};
let tmp = path.with_extension("zdbview.tmp");
let write = std::fs::write(&tmp, &new_bytes).and_then(|_| std::fs::rename(&tmp, &path));
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
self.notify(format!("write failed: {}", e));
return;
}
if let Store::Rkyv(r) = &mut self.store {
r.bytes = new_bytes.into();
}
self.reload_rkyv();
self.notify(ok_msg);
}
fn delete_current_record(&mut self) {
let (key, del_key, kind, bytes) = match self.rkyv_ctx() {
Some(v) => v,
None => return,
};
self.rkyv_apply(
crate::formats::delete_record(&bytes, kind, &del_key),
format!("deleted: {}", key),
);
}
fn commit_add_record(&mut self, key: &str) {
if key.is_empty() {
self.notify("add cancelled: empty key");
return;
}
let (kind, bytes) = match self.rkyv_kind_bytes() {
Some(v) => v,
None => return,
};
self.rkyv_apply(
crate::formats::add_record(&bytes, kind, key, Vec::new()),
format!("added: {}", key),
);
}
fn open_hex_editor(&mut self) {
let (key, value) = match self
.decoded
.as_ref()
.and_then(|d| d.records.get(self.record_idx))
{
Some(rec) => (rec.key.clone(), rec.value.clone()),
None => return,
};
self.hex = Some(HexEdit::new(key, value));
self.hex_from = self.screen;
self.hex_target = HexTarget::Record;
self.screen = Screen::HexEdit;
}
fn key_hex(&mut self, key: KeyEvent) {
let action = match self.hex.as_mut() {
Some(ed) => ed.on_key(key),
None => {
self.screen = Screen::Main;
return;
}
};
match action {
hexedit::Action::None => {}
hexedit::Action::Save => self.commit_hex_value(),
hexedit::Action::Close => {
self.hex = None;
self.screen = self.hex_from;
if self.screen == Screen::Detail {
self.load_detail_value(false);
}
}
}
}
fn commit_hex_value(&mut self) {
let value = match self.hex.as_ref() {
Some(ed) => ed.bytes.clone(),
None => return,
};
if let HexTarget::Cell { table, key, column } = self.hex_target.clone() {
let n = value.len();
let written = match self.sqlite() {
Some(s) => s.update_cell_blob_keyed(&table, &key, &column, &value),
None => return,
};
match written {
Ok(0) => self.notify("the row that cell belonged to is gone".to_string()),
Ok(_) => {
self.load_table();
self.notify(format!("{column} set ({n} bytes)"));
if let Some(ed) = self.hex.as_mut() {
ed.mark_saved();
}
}
Err(e) => self.notify(format!("write failed: {e}")),
}
return;
}
let (_, del_key, kind, bytes) = match self.rkyv_ctx() {
Some(v) => v,
None => return,
};
let n = value.len();
self.rkyv_apply(
crate::formats::set_value(&bytes, kind, &del_key, value),
format!("value set ({} bytes)", n),
);
if let Some(ed) = self.hex.as_mut() {
ed.mark_saved();
}
}
fn commit_rename_record(&mut self, new_key: &str) {
if new_key.is_empty() {
self.notify("rename cancelled");
return;
}
let (_, del_key, kind, bytes) = match self.rkyv_ctx() {
Some(v) => v,
None => return,
};
self.rkyv_apply(
crate::formats::rename_record(&bytes, kind, &del_key, new_key),
format!("renamed → {}", new_key),
);
}
fn reload_rkyv(&mut self) {
let (strings, decoded) = match &self.store {
Store::Rkyv(r) => (r.strings(MIN_STRING), crate::formats::try_decode(&r.bytes)),
_ => return,
};
self.strings_truncated = strings.truncated;
self.strings_scanned = strings.scanned;
self.strings = strings.hits;
self.decoded = decoded;
let n = self.decoded.as_ref().map(|d| d.records.len()).unwrap_or(0);
if self.record_idx >= n {
self.record_idx = n.saturating_sub(1);
}
if n == 0 {
self.rkyv_view = RkyvView::Info;
}
}
fn sqlite(&self) -> Option<&SqliteStore> {
match &self.store {
Store::Sqlite(s) => Some(s),
_ => None,
}
}
fn current_table(&self) -> Option<String> {
self.sqlite()
.and_then(|s| s.tables.get(self.table_idx).cloned())
}
fn current_rowid(&self) -> Option<i64> {
self.rows
.as_ref()
.and_then(|r| r.rowids.get(self.row_idx).copied().flatten())
}
fn current_key(&self) -> Option<crate::sqlite::RowKey> {
if let Some(id) = self.current_rowid() {
return Some(crate::sqlite::RowKey::Rowid(id));
}
let view = self.rows.as_ref()?;
if view.primary_key.is_empty() {
return None;
}
let row = view.rows.get(self.row_idx)?;
let mut pairs = Vec::with_capacity(view.primary_key.len());
for name in &view.primary_key {
let i = view.columns.iter().position(|c| c == name)?;
let value = row.get(i)?;
if value.starts_with("<blob ") {
return None;
}
pairs.push((name.clone(), value.clone()));
}
Some(crate::sqlite::RowKey::Primary(pairs))
}
fn no_key_reason(&self) -> &'static str {
match self.rows.as_ref() {
Some(v) if v.primary_key.is_empty() => {
"row has no rowid and the table has no primary key — cannot edit in place"
}
_ => "the primary key of this row holds a blob — cannot address it as text",
}
}
fn select_table(&mut self, idx: usize) {
let n = self.sqlite().map(|s| s.tables.len()).unwrap_or(0);
if n == 0 {
return;
}
self.table_idx = idx.min(n - 1);
self.page_offset = 0;
self.row_idx = 0;
self.col_idx = 0;
self.sort = None;
self.load_table();
}
fn sort_by_current_column(&mut self) {
let col = match self
.rows
.as_ref()
.and_then(|r| r.columns.get(self.col_idx))
.cloned()
{
Some(c) => c,
None => return,
};
self.sort = match self.sort.take() {
Some(s) if s.column == col && !s.desc => Some(Sort {
column: col.clone(),
desc: true,
}),
Some(s) if s.column == col => None,
_ => Some(Sort {
column: col.clone(),
desc: false,
}),
};
self.page_offset = 0;
self.row_idx = 0;
self.load_table();
match &self.sort {
Some(s) => {
let dir = if s.desc { "descending" } else { "ascending" };
self.notify(format!("sorted by {} {}", s.column, dir))
}
None => self.notify("sort cleared (rowid order)"),
}
}
fn sort_shift_column(&mut self, forward: bool) {
let columns = match self.rows.as_ref().map(|r| r.columns.clone()) {
Some(c) if !c.is_empty() => c,
_ => return,
};
let desc = self.sort.as_ref().is_some_and(|s| s.desc);
let cur = self
.sort
.as_ref()
.and_then(|s| columns.iter().position(|c| *c == s.column));
let next = match cur {
Some(i) if forward => (i + 1) % columns.len(),
Some(i) => (i + columns.len() - 1) % columns.len(),
None => self.col_idx.min(columns.len() - 1),
};
self.col_idx = next;
self.sort = Some(Sort {
column: columns[next].clone(),
desc,
});
self.page_offset = 0;
self.row_idx = 0;
self.load_table();
let dir = if desc { "descending" } else { "ascending" };
self.notify(format!("sorted by {} {}", columns[next], dir));
}
fn load_table(&mut self) {
let Some(table) = self.current_table() else {
return;
};
let loaded_offset = self.loaded_offset;
let hint = self.rows.as_ref().and_then(|r| {
Some(crate::sqlite::PageHint {
offset: loaded_offset,
first: r.rowids.first().copied().flatten()?,
last: r.rowids.last().copied().flatten()?,
len: r.rows.len() as i64,
})
});
let page = crate::query::PageReq {
table: table.clone(),
limit: PAGE,
offset: self.page_offset,
sort: self.sort.clone(),
filter: self.filter.clone(),
hint,
known_total: self.known_total(),
};
let count = match self.exact_total {
Some((ref t, ref f, _)) if *t == table && *f == self.filter => None,
_ => Some(crate::query::CountReq {
table,
filter: self.filter.clone(),
}),
};
if let Some(engine) = self.engine.as_mut() {
self.page_generation = engine.request(page, count);
if let Some(done) = engine.wait_page(PAGE_GRACE) {
self.install_page(done);
}
}
}
fn install_page(&mut self, done: crate::query::PageDone) {
match done.result {
Ok(v) => {
self.loaded_offset = self.page_offset;
self.rows = Some(v);
let loaded = self.rows.as_ref().map(|r| r.rows.len()).unwrap_or(0);
if self.row_idx >= loaded {
self.row_idx = loaded.saturating_sub(1);
}
if let (Some(n), Some(rows)) = (self.known_total(), self.rows.as_mut()) {
rows.total = n;
rows.total_exact = true;
}
}
Err(e) => self.status = format!("load: {e}"),
}
}
fn poll_query(&mut self) {
let Some(engine) = self.engine.as_mut() else {
return;
};
let page = engine.poll_page();
let count = engine.poll_count();
let found = engine.poll_search();
if let Some(done) = page {
self.install_page(done);
}
if let Some(done) = found {
self.install_search(done);
}
if let Some(done) = count {
if let Ok(n) = done.result {
self.exact_total = Some((done.table.clone(), done.filter.clone(), n));
if self.pending_bottom {
self.pending_bottom = false;
self.goto_bottom_at(n);
}
if let Some(rows) = self.rows.as_mut() {
rows.total = n;
rows.total_exact = true;
}
}
}
}
fn schema_changed(&mut self) {
let path = match &mut self.store {
Store::Sqlite(s) => {
s.invalidate();
Some(s.path.clone())
}
Store::Rkyv(_) => None,
};
if let Some(path) = path {
self.engine = Some(crate::query::Engine::new(&path));
}
self.exact_total = None;
}
fn rows_changed(&mut self) {
self.exact_total = None;
}
fn counting(&self) -> bool {
self.engine.as_ref().is_some_and(|e| e.count_inflight())
}
fn loading(&self) -> bool {
self.engine.as_ref().is_some_and(|e| e.page_inflight())
}
fn searching(&self) -> bool {
self.engine.as_ref().is_some_and(|e| e.searching())
}
fn known_total(&self) -> Option<i64> {
let table = self.current_table()?;
match &self.exact_total {
Some((t, f, n)) if *t == table && *f == self.filter => Some(*n),
_ => None,
}
}
fn step_visible(visible: &[usize], cur: usize, delta: isize) -> Option<usize> {
if visible.is_empty() {
return None;
}
let pos = visible.iter().position(|&i| i == cur).unwrap_or(0) as isize;
let next = (pos + delta).clamp(0, visible.len() as isize - 1) as usize;
Some(visible[next])
}
fn poll_decode(&mut self) {
let result = match self.decoding.as_ref() {
Some(rx) => match rx.try_recv() {
Ok(d) => d,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => None,
},
None => return,
};
self.decoding = None;
self.decoded = result;
match &self.decoded {
Some(d) => {
let (format, records) = (d.format.clone(), d.records.len());
self.rkyv_view = RkyvView::Records;
self.notify(format!("{} · {} records", format, records));
}
None => self.notify("unrecognized archive — structural view"),
}
}
fn page_step(&self) -> usize {
self.page_rows.max(1)
}
fn page_sqlite(&mut self, down: bool) {
let step = self.page_step();
match self.focus {
Focus::Left => {
let visible = self.visible_tables();
let delta = if down {
step as isize
} else {
-(step as isize)
};
if let Some(i) = Self::step_visible(&visible, self.table_idx, delta) {
self.select_table(i);
}
}
Focus::Right => {
let (loaded, total) = match &self.rows {
Some(r) => (r.rows.len(), r.total),
None => return,
};
if loaded == 0 {
return;
}
if down {
if self.row_idx + step < loaded {
self.row_idx += step;
} else if self.page_offset + PAGE < total {
self.page(PAGE);
} else {
self.row_idx = loaded - 1;
}
} else if self.row_idx >= step {
self.row_idx -= step;
} else if self.page_offset > 0 {
self.page(-PAGE);
self.row_idx = self.rows.as_ref().map(|r| r.rows.len()).unwrap_or(1) - 1;
} else {
self.row_idx = 0;
}
}
}
}
fn move_selection(&mut self, delta: isize) {
match &self.store {
Store::Sqlite(_) => match self.focus {
Focus::Left => {
let visible = self.visible_tables();
if let Some(i) = Self::step_visible(&visible, self.table_idx, delta) {
self.select_table(i);
}
}
Focus::Right => {
let loaded = self.rows.as_ref().map(|r| r.rows.len()).unwrap_or(0);
if loaded == 0 {
return;
}
let next = (self.row_idx as isize + delta).clamp(0, loaded as isize - 1);
self.row_idx = next as usize;
}
},
Store::Rkyv(_) => self.move_rkyv(delta),
}
}
fn move_rkyv(&mut self, delta: isize) {
match self.rkyv_view {
RkyvView::Records => {
let visible = self.visible_records();
if let Some(i) = Self::step_visible(&visible, self.record_idx, delta) {
self.record_idx = i;
}
}
RkyvView::Strings => {
let visible = self.visible_strings();
if let Some(i) = Self::step_visible(&visible, self.string_idx, delta) {
self.string_idx = i;
}
}
RkyvView::Hex => {
let rows = match &self.store {
Store::Rkyv(r) => r.len().div_ceil(16),
_ => 0,
};
let next = (self.hex_row as isize + delta).max(0) as usize;
self.hex_row = next.min(rows.saturating_sub(1));
}
RkyvView::Info => {}
}
}
fn page_rkyv(&mut self, down: bool) {
let step = self.page_step() as isize;
self.move_rkyv(if down { step } else { -step });
}
fn page(&mut self, delta: i64) {
if self.focus != Focus::Right {
return;
}
let total = self.rows.as_ref().map(|r| r.total).unwrap_or(0);
let next = (self.page_offset + delta).max(0);
if next < total {
self.page_offset = next;
self.row_idx = 0;
self.load_table();
}
}
fn begin_edit_cell(&mut self) {
if self.focus != Focus::Right {
return;
}
self.edit_current_value()
}
fn edit_cell_as_bytes(&mut self) {
if matches!(self.store, Store::Rkyv(_)) {
return self.open_hex_editor();
}
let column = self
.rows
.as_ref()
.and_then(|r| r.columns.get(self.col_idx).cloned());
let (table, column, key) = match (self.current_table(), column, self.current_key()) {
(Some(t), Some(c), Some(k)) => (t, c, k),
_ => {
let why = self.no_key_reason();
self.notify(why);
return;
}
};
let bytes = match self.sqlite() {
Some(s) => s
.cell_bytes_keyed(&table, &key, &column)
.unwrap_or_default(),
None => return,
};
let n = bytes.len();
self.hex = Some(HexEdit::new(format!("{table}.{column}"), bytes));
self.hex_from = self.screen;
self.hex_target = HexTarget::Cell {
table,
key,
column: column.clone(),
};
self.screen = Screen::HexEdit;
self.notify(format!(
"{column} as bytes — {n} to start, ^s writes it back as a blob"
));
}
fn edit_current_value(&mut self) {
if matches!(self.store, Store::Rkyv(_)) {
self.open_hex_editor();
return;
}
let cur = self
.rows
.as_ref()
.and_then(|r| r.rows.get(self.row_idx))
.and_then(|row| row.get(self.col_idx))
.cloned()
.unwrap_or_default();
let key = match self.current_key() {
Some(k) => k,
None => {
let why = self.no_key_reason();
self.notify(why);
return;
}
};
let column = self
.rows
.as_ref()
.and_then(|r| r.columns.get(self.col_idx).cloned());
if let (Some(table), Some(column), Some(store)) =
(self.current_table(), column, self.sqlite())
{
if store
.cell_is_blob_keyed(&table, &key, &column)
.unwrap_or(false)
{
match store.cell_bytes_keyed(&table, &key, &column) {
Ok(bytes) => {
let n = bytes.len();
self.hex = Some(HexEdit::new(format!("{table}.{column}"), bytes));
self.hex_from = self.screen;
self.hex_target = HexTarget::Cell { table, key, column };
self.screen = Screen::HexEdit;
self.notify(format!("blob cell — {n} bytes in the hex editor"));
return;
}
Err(e) => {
self.notify(format!("cannot read the blob: {e}"));
return;
}
}
}
}
self.open_modal(Mode::EditCell(cur));
}
fn commit_edit_cell(&mut self, val: &str) {
let (table, key, col) = match (
self.current_table(),
self.current_key(),
self.rows
.as_ref()
.and_then(|r| r.columns.get(self.col_idx).cloned()),
) {
(Some(t), Some(rid), Some(c)) => (t, rid, c),
_ => return,
};
let res = self
.sqlite()
.unwrap()
.update_cell_keyed(&table, &key, &col, val);
match res {
Ok(0) => self.notify("that row is no longer there".to_string()),
Ok(_) => {
self.notify(format!("updated {}.{}", table, col));
self.load_table();
if self.screen == Screen::Detail {
self.load_detail_value(false);
}
}
Err(e) => self.status = format!("update failed: {}", e),
}
}
fn insert_row(&mut self) {
let table = match self.current_table() {
Some(t) => t,
None => return,
};
match self.sqlite().unwrap().insert_blank(&table) {
Ok(()) => {
self.notify(format!("inserted default row into {}", table));
self.rows_changed();
self.load_table();
}
Err(e) => self.status = format!("insert failed: {}", e),
}
}
fn delete_current_row(&mut self) {
let (table, key) = match (self.current_table(), self.current_key()) {
(Some(t), Some(k)) => (t, k),
_ => {
let why = self.no_key_reason();
self.notify(why);
return;
}
};
let what = match &key {
crate::sqlite::RowKey::Rowid(id) => format!("row {id}"),
crate::sqlite::RowKey::Primary(pairs) => pairs
.iter()
.map(|(c, v)| format!("{c}={v}"))
.collect::<Vec<_>>()
.join(", "),
};
match self.sqlite().unwrap().delete_row_keyed(&table, &key) {
Ok(0) => self.notify("that row is no longer there".to_string()),
Ok(_) => {
self.notify(format!("deleted {what} from {table}"));
self.row_idx = self.row_idx.saturating_sub(1);
self.rows_changed();
self.load_table();
}
Err(e) => self.status = format!("delete failed: {}", e),
}
}
fn goto_top(&mut self) {
match self.focus {
Focus::Left => self.select_table(0),
Focus::Right => {
self.page_offset = 0;
self.row_idx = 0;
self.load_table();
}
}
}
fn goto_bottom(&mut self) {
match self.focus {
Focus::Left => {
let n = self.sqlite().map(|s| s.tables.len()).unwrap_or(0);
if n > 0 {
self.select_table(n - 1);
}
}
Focus::Right => match self.known_total() {
Some(total) => self.goto_bottom_at(total),
None => {
self.pending_bottom = true;
if let (Some(table), Some(engine)) =
(self.current_table(), self.engine.as_mut())
{
engine.request_count(crate::query::CountReq {
table,
filter: self.filter.clone(),
});
}
self.status = "counting rows for the last page…".into();
}
},
}
}
fn goto_bottom_at(&mut self, total: i64) {
if total <= 0 {
return;
}
self.page_offset = ((total - 1) / PAGE) * PAGE;
self.row_idx = ((total - 1) % PAGE) as usize;
self.load_table();
}
fn rkyv_goto_top(&mut self) {
match self.rkyv_view {
RkyvView::Records => self.record_idx = 0,
RkyvView::Strings => self.string_idx = 0,
RkyvView::Hex => self.hex_row = 0,
RkyvView::Info => {}
}
}
fn rkyv_goto_bottom(&mut self) {
match self.rkyv_view {
RkyvView::Records => {
self.record_idx = self
.decoded
.as_ref()
.map(|d| d.records.len().saturating_sub(1))
.unwrap_or(0);
}
RkyvView::Strings => self.string_idx = self.strings.len().saturating_sub(1),
RkyvView::Hex => {
let len = match &self.store {
Store::Rkyv(r) => r.len(),
_ => 0,
};
self.hex_row = len.saturating_sub(1) / 16;
}
RkyvView::Info => {}
}
}
fn search_next(&mut self, forward: bool) {
if self.search.is_empty() {
return;
}
match &self.store {
Store::Sqlite(_) => self.search_sqlite(forward),
Store::Rkyv(_) => self.search_rkyv(forward),
}
}
fn search_sqlite(&mut self, forward: bool) {
let term = self.search.to_lowercase();
match self.focus {
Focus::Left => {
let tables = self.sqlite().map(|s| s.tables.clone()).unwrap_or_default();
let visible = self.visible_tables();
match find_next_visible(&visible, self.table_idx, forward, |i| {
tables[i].to_lowercase().contains(&term)
}) {
Some(i) => self.select_table(i),
None => self.status = format!("not found: {}", self.search),
}
}
Focus::Right => self.search_sqlite_table(forward),
}
}
fn search_sqlite_table(&mut self, forward: bool) {
let (table, columns) = match (
self.current_table(),
self.rows.as_ref().map(|r| r.columns.clone()),
) {
(Some(t), Some(c)) => (t, c),
_ => return,
};
let req = crate::query::SearchReq {
table,
columns,
term: self.search.clone(),
sort: self.sort.clone(),
filter: self.filter.clone(),
from: self.current_rowid(),
forward,
};
let Some(engine) = self.engine.as_mut() else {
return;
};
engine.request_search(req);
self.status = format!("searching for {}…", self.search);
}
fn install_search(&mut self, done: crate::query::SearchDone) {
match done.result {
Ok(Some((_rowid, ordinal))) => {
let idx0 = (ordinal - 1).max(0);
self.page_offset = (idx0 / PAGE) * PAGE;
self.load_table();
self.row_idx = (idx0 - self.page_offset) as usize;
let total = self
.known_total()
.or_else(|| self.rows.as_ref().map(|r| r.total))
.unwrap_or(0);
self.notify(format!("/{} (row {} of {})", self.search, ordinal, total));
}
Ok(None) => self.status = format!("not found: {}", self.search),
Err(e) => self.status = format!("search error: {}", e),
}
}
fn search_rkyv(&mut self, forward: bool) {
let term = self.search.to_lowercase();
match self.rkyv_view {
RkyvView::Records => {
let keys: Vec<String> = self
.decoded
.as_ref()
.map(|d| d.records.iter().map(|r| r.key.to_lowercase()).collect())
.unwrap_or_default();
let visible = self.visible_records();
match find_next_visible(&visible, self.record_idx, forward, |i| {
keys[i].contains(&term)
}) {
Some(i) => self.record_idx = i,
None => self.status = format!("not found: {}", self.search),
}
}
RkyvView::Strings => {
let visible = self.visible_strings();
match find_next_visible(&visible, self.string_idx, forward, |i| {
self.strings[i].text.to_lowercase().contains(&term)
}) {
Some(i) => self.string_idx = i,
None => self.status = format!("not found: {}", self.search),
}
}
RkyvView::Hex => {
let bytes = match &self.store {
Store::Rkyv(r) => r.bytes.clone(),
_ => return,
};
let cur = self.hex_row * 16;
match find_bytes(&bytes, self.search.as_bytes(), cur, forward) {
Some(off) => {
self.hex_row = off / 16;
self.notify(format!("/{} (offset {:#x})", self.search, off));
}
None => self.status = format!("not found: {}", self.search),
}
}
RkyvView::Info => {}
}
}
fn render(&mut self, f: &mut Frame) {
let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
let body = outer[0].height as usize;
self.page_rows = match self.screen {
Screen::Detail => body.saturating_sub(11),
Screen::Main if matches!(self.store, Store::Sqlite(_)) => body.saturating_sub(3),
_ => body.saturating_sub(2),
}
.max(1);
match self.screen {
Screen::Detail => self.render_detail(f, outer[0]),
Screen::HexEdit => self.render_hex(f, outer[0]),
Screen::Top => {
let t = self.ov.theme;
self.top_area = outer[0];
if let Some(m) = self.top.as_ref() {
m.render(f, outer[0], &t);
}
}
Screen::Sql => {
let t = self.ov.theme;
self.sql_area = outer[0];
if let Some(e) = self.sql.as_mut() {
e.render(f, outer[0], &t);
}
}
Screen::DbInfo => self.render_dbinfo(f, outer[0]),
Screen::Stats => self.render_stats(f, outer[0]),
Screen::Frames => {
let t = self.ov.theme;
if let Some(w) = self.walk.as_mut() {
w.render(f, outer[0], &t);
}
}
Screen::Schema => self.render_schema(f, outer[0]),
Screen::Main => match &self.store {
Store::Sqlite(_) => self.render_sqlite(f, outer[0]),
Store::Rkyv(_) => self.render_rkyv(f, outer[0]),
},
}
self.render_status(f, outer[1]);
match &self.mode {
Mode::EditCell(buf) => self.render_input(f, "edit cell (Enter=save, Esc=cancel)", buf),
Mode::Search(buf) => self.render_input(f, "search / (Enter, Esc)", buf),
Mode::AddRecord(buf) => self.render_input(f, "new record key (Enter=add, Esc)", buf),
Mode::RenameRecord(buf) => self.render_input(f, "rename key to (Enter, Esc)", buf),
Mode::ConfirmDelete => {
let what = match self.store {
Store::Sqlite(_) => "row",
Store::Rkyv(_) => "record (rewrites the cache file)",
};
self.render_input(f, &format!("delete this {}? (y = yes, any = no)", what), "")
}
Mode::Normal => {}
}
self.ov.render(f, self.help_ctx());
}
fn render_detail(&self, f: &mut Frame, area: Rect) {
let rows = Layout::vertical([Constraint::Length(9), Constraint::Min(3)]).split(area);
let mut fields: Vec<Line> = Vec::new();
let title;
match &self.store {
Store::Sqlite(_) => {
title = " row detail ".to_string();
if let Some(rv) = &self.rows {
if let Some(row) = rv.rows.get(self.row_idx) {
for (i, col) in rv.columns.iter().enumerate() {
let sel = i == self.col_idx;
fields.push(Line::from(vec![
Span::styled(
format!("{:<20}", truncate(col, 20)),
Style::default().fg(if sel {
self.ov.theme.accent
} else {
self.ov.theme.dim
}),
),
Span::raw(truncate(
row.get(i).map(|s| s.as_str()).unwrap_or(""),
80,
)),
]));
}
}
}
}
Store::Rkyv(_) => {
title = " record detail ".to_string();
if let Some(rec) = self
.decoded
.as_ref()
.and_then(|d| d.records.get(self.record_idx))
{
fields.push(Line::from(vec![
Span::styled(
format!("{:<20}", "key"),
Style::default().fg(self.ov.theme.accent),
),
Span::raw(truncate(&rec.key, 80)),
]));
for (name, val) in &rec.fields {
fields.push(Line::from(vec![
Span::styled(
format!("{:<20}", truncate(name, 20)),
Style::default().fg(self.ov.theme.dim),
),
Span::raw(val.clone()),
]));
}
}
}
}
f.render_widget(
Paragraph::new(fields).block(Block::default().borders(Borders::ALL).title(title)),
rows[0],
);
let height = rows[1].height.saturating_sub(2) as usize;
let lines = value_lines(
&self.detail_value,
self.value_render,
self.detail_scroll,
height,
);
f.render_widget(
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(format!(
" value — {} bytes — render: {} (v to cycle · y copy · Esc back) ",
self.detail_value.len(),
self.value_render.label()
))),
rows[1],
);
}
fn open_top(&mut self) {
let mut targets = vec![match &self.store {
Store::Sqlite(s) => (s.path.clone(), Kind::Sqlite),
Store::Rkyv(r) => (r.path.clone(), Kind::Rkyv),
}];
targets.extend(watch_targets());
let m = crate::monitor::Monitor::new(targets);
if m.is_empty() {
self.notify("nothing to watch yet");
return;
}
let n = m.len();
self.top = Some(m);
self.screen = Screen::Top;
self.notify(format!("watching {} files for writes", n));
}
fn open_stats(&mut self) {
let table = match self.current_table() {
Some(t) => t,
None => return,
};
let path = match self.sqlite() {
Some(s) => s.path.clone(),
None => return,
};
self.stats.clear();
self.stats_freq = None;
self.stats_idx = 0;
self.screen = Screen::Stats;
self.stats_pending = Some((table.clone(), spawn_stats(path, table.clone())));
self.status = format!("{table} · analyzing columns …");
}
fn poll_stats(&mut self) {
let (table, result) = match self.stats_pending.as_ref() {
Some((t, rx)) => match rx.try_recv() {
Ok(r) => (t.clone(), r),
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
(t.clone(), Err("the analysis thread stopped".into()))
}
},
None => return,
};
self.stats_pending = None;
match result {
Ok(v) => {
let n = v.len();
self.stats = v;
self.stats_idx = self.col_idx.min(n.saturating_sub(1));
self.status =
format!("{table} · {n} columns · Enter frequency · j/k move · Esc back");
}
Err(e) => {
self.screen = Screen::Main;
self.notify(format!("stats failed: {e}"));
}
}
}
fn key_stats(&mut self, code: KeyCode) {
match code {
KeyCode::Char('q') => self.quit = true,
KeyCode::Esc | KeyCode::Char('A') => {
if self.stats_freq.is_some() {
self.stats_freq = None;
} else {
self.screen = Screen::Main;
}
}
KeyCode::Down | KeyCode::Char('j') => {
self.stats_idx = (self.stats_idx + 1).min(self.stats.len().saturating_sub(1));
self.stats_freq = None;
}
KeyCode::Up | KeyCode::Char('k') => {
self.stats_idx = self.stats_idx.saturating_sub(1);
self.stats_freq = None;
}
KeyCode::Char('g') => {
self.stats_idx = 0;
self.stats_freq = None;
}
KeyCode::Char('G') => {
self.stats_idx = self.stats.len().saturating_sub(1);
self.stats_freq = None;
}
KeyCode::Enter | KeyCode::Char('f') if self.stats_pending.is_none() => {
self.open_frequency()
}
_ => {}
}
}
fn open_frequency(&mut self) {
let (table, column) = match (
self.current_table(),
self.stats.get(self.stats_idx).map(|c| c.name.clone()),
) {
(Some(t), Some(c)) => (t, c),
_ => return,
};
let rows = match self.sqlite() {
Some(s) => s.frequency(&table, &column, FREQUENCY_ROWS),
None => return,
};
match rows {
Ok(v) => self.stats_freq = Some((column, v)),
Err(e) => self.notify(format!("frequency failed: {e}")),
}
}
fn render_stats(&mut self, f: &mut Frame, area: Rect) {
let t = self.ov.theme;
let mut lines: Vec<Line> = Vec::new();
let head = format!(
" {:<16} {:<8} {:>7} {:>6} {:>8} {:>7} {:>7} {:>9} {:>9} {:>9}",
"column",
"type",
"rows",
"nulls",
"distinct",
"numeric",
"longest",
"min",
"max",
"mean"
);
lines.push(Line::from(Span::styled(
head,
Style::default().fg(t.primary).add_modifier(Modifier::BOLD),
)));
for (i, c) in self.stats.iter().enumerate() {
let mean = match c.avg {
Some(a) => format!("{a:.3}"),
None => "—".to_string(),
};
let text = format!(
" {:<16} {:<8} {:>7} {:>6} {:>8} {:>7} {:>7} {:>9} {:>9} {:>9}",
truncate(&c.name, 16),
truncate(&c.declared, 8),
c.rows,
c.nulls,
c.distinct,
c.numeric,
c.longest,
truncate(&c.min, 9),
truncate(&c.max, 9),
truncate(&mean, 9),
);
let style = if i == self.stats_idx {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(t.primary)
};
lines.push(Line::from(Span::styled(text, style)));
}
if let Some((col, freq)) = &self.stats_freq {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {col} — most common values"),
Style::default().fg(t.primary).add_modifier(Modifier::BOLD),
)));
let widest = freq.iter().map(|(_, n)| *n).max().unwrap_or(1).max(1);
for (v, n) in freq {
let bar = "#".repeat(((*n as f64 / widest as f64) * 24.0).round() as usize);
lines.push(Line::from(vec![
Span::styled(
format!(" {:<32} {:>9} ", truncate(v, 32), n),
Style::default().fg(t.primary),
),
Span::styled(bar, Style::default().fg(t.accent)),
]));
}
}
let height = area.height.saturating_sub(2) as usize;
let first = (self.stats_idx + 2).saturating_sub(height);
let shown: Vec<Line> = lines.into_iter().skip(first).take(height).collect();
let title = match (&self.stats_pending, self.current_table()) {
(Some((t, _)), _) => format!(" columns of {t} — analyzing … "),
(None, Some(t)) => format!(" columns of {t} — Enter frequency · Esc back "),
(None, None) => " columns ".to_string(),
};
f.render_widget(
Paragraph::new(shown).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.accent))
.title(title),
),
area,
);
}
fn follow_foreign_key(&mut self) {
let (table, columns, value) = match (self.current_table(), self.rows.as_ref()) {
(Some(t), Some(v)) => {
let value = match v.rows.get(self.row_idx).and_then(|r| r.get(self.col_idx)) {
Some(v) => v.clone(),
None => return,
};
(t, v.columns.clone(), value)
}
_ => return,
};
let column = match columns.get(self.col_idx) {
Some(c) => c.clone(),
None => return,
};
let keys = match self.sqlite() {
Some(s) => s.foreign_keys(&table).unwrap_or_default(),
None => return,
};
let (parent, parent_col) = match keys
.iter()
.find(|(child, _, _)| child.eq_ignore_ascii_case(&column))
{
Some((_, parent, parent_col)) => (parent.clone(), parent_col.clone()),
None => {
self.notify(format!("{column} is not a foreign key"));
return;
}
};
if value == "NULL" {
self.notify(format!("{column} is NULL — nothing to follow"));
return;
}
let found = match self.sqlite() {
Some(s) => {
if parent_col == "rowid" {
value.parse::<i64>().ok()
} else {
s.rowid_where(&parent, &parent_col, &value).unwrap_or(None)
}
}
None => return,
};
let rowid = match found {
Some(r) => r,
None => {
self.notify(format!("no {parent} row with {parent_col} = {value}"));
return;
}
};
let idx = match self
.sqlite()
.and_then(|s| s.tables.iter().position(|t| *t == parent))
{
Some(i) => i,
None => {
self.notify(format!("{parent} is not listed"));
return;
}
};
self.filter.clear();
self.select_table(idx);
self.focus = Focus::Right;
self.goto_rowid(rowid, &parent_col);
self.notify(format!(
"{table}.{column} → {parent}.{parent_col} = {value}"
));
}
fn goto_rowid(&mut self, rowid: i64, column: &str) {
let ord = match (self.current_table(), self.sqlite()) {
(Some(t), Some(s)) => s
.rowid_ordinal(&t, rowid, self.sort.as_ref(), &self.filter)
.unwrap_or(1),
_ => return,
};
let idx0 = (ord - 1).max(0);
self.page_offset = (idx0 / PAGE) * PAGE;
self.load_table();
self.row_idx = (idx0 - self.page_offset) as usize;
if let Some(i) = self
.rows
.as_ref()
.and_then(|r| r.columns.iter().position(|c| c == column))
{
self.col_idx = i;
}
}
fn copy_row_as_insert(&mut self) {
let (table, view) = match (self.current_table(), self.rows.as_ref()) {
(Some(t), Some(v)) => (t, v),
_ => return,
};
let row = match view.rows.get(self.row_idx) {
Some(r) => r.clone(),
None => return,
};
let sql = crate::export::insert_statement(&table, &view.columns, &row);
let ok = crate::clipboard::copy(&sql);
self.notify(if ok {
format!("copied INSERT ({} chars)", sql.len())
} else {
"clipboard unavailable (no tty)".to_string()
});
}
fn open_dbinfo(&mut self) {
let info = match self.sqlite() {
Some(s) => s.db_info(),
None => return,
};
self.dbinfo = info;
self.dbinfo_checks.clear();
self.dbinfo_scroll = 0;
self.screen = Screen::DbInfo;
self.status = "database · i integrity · Q quick · f fk lint · v vacuum · z analyze \
· r reindex · Esc back"
.into();
}
fn key_dbinfo(&mut self, code: KeyCode) {
match code {
KeyCode::Esc | KeyCode::Char('D') => {
self.screen = Screen::Main;
self.dbinfo_scroll = 0;
}
KeyCode::Char('q') => self.quit = true,
KeyCode::Char('i') | KeyCode::Char('Q') => {
let quick = code == KeyCode::Char('Q');
let out = match self.sqlite() {
Some(s) => s.integrity_check(quick),
None => return,
};
self.dbinfo_checks = match out {
Ok(lines) if lines == ["ok".to_string()] => {
vec![format!(
"{} check: ok",
if quick { "quick" } else { "integrity" }
)]
}
Ok(lines) => lines,
Err(e) => vec![format!("check failed: {e}")],
};
}
KeyCode::Char('v') => self.maintain(crate::sqlite::Maintenance::Vacuum),
KeyCode::Char('z') => self.maintain(crate::sqlite::Maintenance::Analyze),
KeyCode::Char('r') => self.maintain(crate::sqlite::Maintenance::Reindex),
KeyCode::Char('f') => {
let out = match self.sqlite() {
Some(s) => s.missing_fk_indexes(),
None => return,
};
self.dbinfo_checks = match out {
Ok(v) if v.is_empty() => {
vec!["foreign-key lint: every foreign key has an index".into()]
}
Ok(v) => v,
Err(e) => vec![format!("lint failed: {e}")],
};
}
KeyCode::Down | KeyCode::Char('j') => self.dbinfo_scroll += 1,
KeyCode::Up | KeyCode::Char('k') => {
self.dbinfo_scroll = self.dbinfo_scroll.saturating_sub(1)
}
KeyCode::PageDown => self.dbinfo_scroll += self.page_step(),
KeyCode::PageUp => {
self.dbinfo_scroll = self.dbinfo_scroll.saturating_sub(self.page_step())
}
KeyCode::Char('g') => self.dbinfo_scroll = 0,
_ => {}
}
}
fn maintain(&mut self, op: crate::sqlite::Maintenance) {
let result = match self.sqlite() {
Some(s) => s.maintain(op),
None => return,
};
let line = match result {
Ok(delta) => {
let size = if delta == 0 {
"no change in size".to_string()
} else if delta < 0 {
format!("{} smaller", human_size((-delta) as u64))
} else {
format!("{} larger", human_size(delta as u64))
};
format!("{}: done, {size}", op.label())
}
Err(e) => format!("{} failed: {e}", op.label()),
};
if let Some(s) = self.sqlite() {
self.dbinfo = s.db_info();
}
self.dbinfo_checks = vec![line.clone()];
self.notify(line);
}
fn render_dbinfo(&mut self, f: &mut Frame, area: Rect) {
let t = self.ov.theme;
let mut lines: Vec<Line> = Vec::new();
let width = self
.dbinfo
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0);
for (k, v) in &self.dbinfo {
lines.push(Line::from(vec![
Span::styled(
format!(" {:>width$} ", k, width = width),
Style::default().fg(t.dim),
),
Span::styled(v.clone(), Style::default().fg(t.primary)),
]));
}
if !self.dbinfo_checks.is_empty() {
lines.push(Line::from(""));
for c in &self.dbinfo_checks {
let ok =
c.ends_with("ok") || c.contains("every foreign key") || c.contains(": done");
lines.push(Line::from(Span::styled(
format!(" {c}"),
Style::default().fg(if ok { t.label } else { t.alt }),
)));
}
}
let height = area.height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(height);
self.dbinfo_scroll = self.dbinfo_scroll.min(max_scroll);
let shown: Vec<Line> = lines
.into_iter()
.skip(self.dbinfo_scroll)
.take(height)
.collect();
f.render_widget(
Paragraph::new(shown).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.accent))
.title(" database — i/Q check · f fk lint · v/z/r maintain · Esc back "),
),
area,
);
}
fn open_sql(&mut self) {
let schema = match self.sqlite() {
Some(s) => s.schema_names(),
None => return,
};
if self.sql.is_none() {
self.sql = Some(crate::sqledit::SqlEdit::new(schema));
}
self.screen = Screen::Sql;
self.status =
"SQL editor · Tab completes · ^j newline · Enter runs · ↑/↓ history · Esc back".into();
}
fn key_sql(&mut self, key: KeyEvent) {
let page = crate::sqledit::SqlEdit::page_rows(self.sql_area);
let action = match self.sql.as_mut() {
Some(e) => e.on_key(key, page),
None => {
self.screen = Screen::Main;
return;
}
};
match action {
crate::sqledit::Action::None => {}
crate::sqledit::Action::Close => self.screen = Screen::Main,
crate::sqledit::Action::Execute(sql) => self.execute_sql(&sql),
crate::sqledit::Action::Explain(sql) => self.explain_sql(&sql),
}
}
fn explain_sql(&mut self, sql: &str) {
use crate::sqledit::Entry;
let plan = match self.sqlite() {
Some(s) => s.explain_plan(sql),
None => return,
};
let entry = match plan {
Ok(steps) if steps.is_empty() => Entry::Plan(vec!["(no plan)".into()]),
Ok(steps) => Entry::Plan(steps),
Err(e) => Entry::Error(e.to_string()),
};
if let Some(e) = self.sql.as_mut() {
e.push(entry);
}
}
fn execute_sql(&mut self, sql: &str) {
use crate::sqledit::{Entry, RESULT_ROWS};
use crate::sqlite::Outcome;
if sql.trim_start().starts_with('.') {
return self.run_dot(sql.trim());
}
if self.sql_eqp {
self.explain_sql(sql);
}
let started = std::time::Instant::now();
let outcome = match self.sqlite() {
Some(s) => s.run(sql, RESULT_ROWS),
None => return,
};
let took = started.elapsed();
let entry = match outcome {
Ok(Outcome::Rows {
columns,
rows,
literals,
truncated,
}) => {
match self.write_result(&columns, &rows, &literals) {
Some(note) => Entry::Note(vec![note]),
None if self.sql_mode == OutputMode::List => Entry::Rows {
columns,
rows,
truncated,
},
None => {
let text = self.sql_mode.render(
&columns,
&rows,
&literals,
&self.current_table().unwrap_or_else(|| "table".into()),
self.sql_headers,
);
Entry::Note(text.lines().map(str::to_string).collect())
}
}
}
Ok(Outcome::Changed(n)) => {
self.schema_changed();
self.load_table();
Entry::Changed(n)
}
Err(e) => Entry::Error(e.to_string()),
};
let timer = self.sql_timer;
if let Some(e) = self.sql.as_mut() {
e.push(entry);
if timer {
e.push(crate::sqledit::Entry::Timing(took));
}
}
}
fn run_dot(&mut self, line: &str) {
use crate::sqledit::Entry;
let mut parts = line.split_whitespace();
let cmd = parts.next().unwrap_or("").to_lowercase();
let args: Vec<&str> = parts.collect();
let arg = |i: usize| args.get(i).copied();
let on_off = |v: Option<&str>, current: bool| match v {
Some("on") | Some("yes") | Some("1") => true,
Some("off") | Some("no") | Some("0") => false,
_ => !current,
};
let note: Vec<String> = match cmd.as_str() {
".help" => DOT_HELP.lines().map(str::to_string).collect(),
".tables" => match self.sqlite() {
Some(s) => s.tables.clone(),
None => return,
},
".schema" => {
let only = arg(0);
match self.sqlite().map(|s| s.schema()) {
Some(Ok(objects)) => objects
.into_iter()
.filter(|(_, name, _)| only.is_none_or(|o| o.eq_ignore_ascii_case(name)))
.flat_map(|(_, _, sql)| {
let mut lines: Vec<String> = sql.lines().map(str::to_string).collect();
lines.push(";".into());
lines
})
.collect(),
Some(Err(e)) => vec![format!("{e}")],
None => return,
}
}
".indexes" => {
let table = match arg(0).map(str::to_string).or_else(|| self.current_table()) {
Some(t) => t,
None => return,
};
match self.sqlite().map(|s| s.indexes(&table)) {
Some(Ok(list)) if list.is_empty() => vec![format!("{table}: no indexes")],
Some(Ok(list)) => list
.into_iter()
.map(|(name, cols)| format!("{name} ({})", cols.join(", ")))
.collect(),
Some(Err(e)) => vec![format!("{e}")],
None => return,
}
}
".dump" => match self.sqlite().map(|s| s.dump(arg(0))) {
Some(Ok(text)) => text.lines().map(str::to_string).collect(),
Some(Err(e)) => vec![format!("{e}")],
None => return,
},
".databases" => match self.sqlite().map(|s| s.databases()) {
Some(Ok(list)) => list
.into_iter()
.map(|(alias, file)| format!("{alias:<10} {file}"))
.collect(),
Some(Err(e)) => vec![format!("{e}")],
None => return,
},
".attach" => match (arg(0), arg(1)) {
(Some(file), Some(alias)) => {
match self
.sqlite()
.map(|s| s.attach(std::path::Path::new(file), alias))
{
Some(Ok(())) => vec![format!("attached {file} as {alias}")],
Some(Err(e)) => vec![format!("{e}")],
None => return,
}
}
_ => vec!["usage: .attach FILE ALIAS".into()],
},
".detach" => match (arg(0), self.sqlite()) {
(Some(alias), Some(s)) => match s.detach(alias) {
Ok(()) => vec![format!("detached {alias}")],
Err(e) => vec![format!("{e}")],
},
(None, _) => vec!["usage: .detach ALIAS".into()],
_ => return,
},
".mode" => match arg(0) {
Some(name) => match OutputMode::parse(name) {
Some(m) => {
self.sql_mode = m;
vec![format!("mode: {}", m.label())]
}
None => vec![format!(
"unknown mode {name:?} — list csv tsv markdown line insert json"
)],
},
None => vec![format!("mode: {}", self.sql_mode.label())],
},
".headers" => {
self.sql_headers = on_off(arg(0), self.sql_headers);
vec![format!(
"headers: {}",
if self.sql_headers { "on" } else { "off" }
)]
}
".timer" => {
self.sql_timer = on_off(arg(0), self.sql_timer);
vec![format!(
"timer: {}",
if self.sql_timer { "on" } else { "off" }
)]
}
".eqp" => {
self.sql_eqp = on_off(arg(0), self.sql_eqp);
vec![format!("eqp: {}", if self.sql_eqp { "on" } else { "off" })]
}
".output" | ".once" => match arg(0) {
Some(file) => {
let path = PathBuf::from(file);
match std::fs::write(&path, b"") {
Ok(()) => {
self.sql_out = Some((path, cmd == ".once"));
vec![format!(
"{} results to {file}",
if cmd == ".once" { "next" } else { "all" }
)]
}
Err(e) => vec![format!("cannot write {file}: {e}")],
}
}
None => {
self.sql_out = None;
vec!["output: the transcript".into()]
}
},
".import" => match (arg(0), arg(1)) {
(Some(file), Some(table)) => self.import_file(file, table),
_ => vec!["usage: .import FILE TABLE".into()],
},
".read" => match arg(0) {
Some(file) => match std::fs::read_to_string(file) {
Ok(text) => {
let statements: Vec<String> = text
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let n = statements.len();
for stmt in statements {
self.execute_sql(&stmt);
}
vec![format!(
"ran {n} statement{} from {file}",
if n == 1 { "" } else { "s" }
)]
}
Err(e) => vec![format!("cannot read {file}: {e}")],
},
None => vec!["usage: .read FILE".into()],
},
".backup" => match (arg(0), self.sqlite()) {
(Some(file), Some(s)) => {
let dest = std::path::Path::new(file);
if dest.exists() {
vec![format!("{file} already exists")]
} else {
match s.backup_to(dest) {
Ok(()) => vec![format!("wrote {file}")],
Err(e) => vec![format!("{e}")],
}
}
}
(None, _) => vec!["usage: .backup FILE".into()],
_ => return,
},
".recover" => {
let path = match self.sqlite() {
Some(s) => s.path.clone(),
None => return,
};
match crate::recover::recover(&path) {
Ok(found) => {
let sql = crate::recover::to_sql(&found);
let dest = arg(0)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("recovered.sql"));
let mut lines = vec![format!(
"{} rows across {} tables, {} in lost_and_found",
found.rows.len(),
found.tables.len(),
found.orphans()
)];
lines.extend(found.notes.clone());
match std::fs::write(&dest, sql.as_bytes()) {
Ok(()) => lines.push(format!("script written to {}", dest.display())),
Err(e) => lines.push(format!("cannot write {}: {e}", dest.display())),
}
lines
}
Err(e) => vec![format!("recover failed: {e}")],
}
}
".expert" => {
let last = self
.sql
.as_ref()
.and_then(|e| e.last_statement())
.unwrap_or_default();
if last.is_empty() {
vec![".expert: run a statement first, then ask about it".into()]
} else {
match self.sqlite().map(|s| s.index_advice(&last)) {
Some(Ok(advice)) if advice.is_empty() => {
vec![format!("{last}: the planner scans nothing in full")]
}
Some(Ok(advice)) => advice,
Some(Err(e)) => vec![format!("{e}")],
None => return,
}
}
}
".vacuum" | ".analyze" | ".reindex" => {
let op = match cmd.as_str() {
".vacuum" => crate::sqlite::Maintenance::Vacuum,
".analyze" => crate::sqlite::Maintenance::Analyze,
_ => crate::sqlite::Maintenance::Reindex,
};
match self.sqlite().map(|s| s.maintain(op)) {
Some(Ok(delta)) => vec![format!(
"{}: done, {}",
op.label(),
if delta == 0 {
"no change in size".to_string()
} else if delta < 0 {
format!("{} smaller", human_size((-delta) as u64))
} else {
format!("{} larger", human_size(delta as u64))
}
)],
Some(Err(e)) => vec![format!("{e}")],
None => return,
}
}
".quit" | ".exit" => {
self.quit = true;
return;
}
other => vec![format!("unknown command {other:?} — .help lists them")],
};
if let Some(e) = self.sql.as_mut() {
e.push(Entry::Note(note));
}
}
fn import_file(&mut self, file: &str, table: &str) -> Vec<String> {
let text = match std::fs::read_to_string(file) {
Ok(t) => t,
Err(e) => return vec![format!("cannot read {file}: {e}")],
};
let sep = match std::path::Path::new(file)
.extension()
.and_then(|e| e.to_str())
{
Some("tsv") | Some("tab") => '\t',
_ => ',',
};
let csv = match crate::import::parse(&text, sep) {
Ok(c) => c,
Err(e) => return vec![format!("{file}: {e}")],
};
let done = match self.sqlite() {
Some(s) => s.import_rows(table, &csv.header, &csv.rows),
None => return Vec::new(),
};
match done {
Ok(n) => {
self.load_table();
vec![format!("imported {n} rows into {table}")]
}
Err(e) => vec![format!("{e}")],
}
}
fn write_result(
&mut self,
columns: &[String],
rows: &[Vec<String>],
literals: &[Vec<String>],
) -> Option<String> {
let (path, once) = self.sql_out.clone()?;
let text = self.sql_mode.render(
columns,
rows,
literals,
&self.current_table().unwrap_or_else(|| "table".into()),
self.sql_headers,
);
let note = match append_to(&path, text.as_bytes()) {
Ok(()) => format!(
"wrote {} row{} to {} as {}",
rows.len(),
if rows.len() == 1 { "" } else { "s" },
path.display(),
self.sql_mode.label()
),
Err(e) => format!("cannot write {}: {e}", path.display()),
};
if once {
self.sql_out = None;
}
Some(note)
}
fn key_top(&mut self, code: KeyCode) {
let action = match self.top.as_mut() {
Some(m) => m.on_key(code, self.page_rows.max(1)),
None => {
self.screen = Screen::Main;
return;
}
};
if let Some(note) = self.top.as_mut().and_then(|m| m.note.take()) {
self.notify(note);
}
match action {
crate::monitor::Action::None => {}
crate::monitor::Action::Back => {
self.top = None;
self.screen = Screen::Main;
}
crate::monitor::Action::Quit => self.quit = true,
crate::monitor::Action::Open(path) => {
self.open_next = Some(path);
self.back_to_files();
}
crate::monitor::Action::Frames(path) => self.open_frames(&path),
}
}
fn open_frames(&mut self, path: &std::path::Path) {
let columns: std::collections::HashMap<String, Vec<String>> = match self.sqlite() {
Some(s) if s.path == path => s.schema_names().into_iter().collect(),
_ => match crate::sqlite::SqliteStore::open(path) {
Ok(s) => s.schema_names().into_iter().collect(),
Err(_) => Default::default(),
},
};
match crate::frames::FrameView::open(path, columns) {
Some(view) => {
let n = view.frames.len();
self.walk = Some(view);
self.screen = Screen::Frames;
self.status =
format!("{n} frames · j/k step · [ ] commits · Esc back to the monitor");
}
None => self.notify("no write-ahead log to walk (journal_mode is not WAL)"),
}
}
fn key_frames(&mut self, code: KeyCode) {
let page = self.page_rows.max(1);
let action = match self.walk.as_mut() {
Some(w) => w.on_key(code, page),
None => {
self.screen = Screen::Top;
return;
}
};
match action {
crate::frames::Action::None => {}
crate::frames::Action::Back => {
self.walk = None;
self.screen = Screen::Top;
}
crate::frames::Action::Quit => self.quit = true,
}
}
fn render_hex(&mut self, f: &mut Frame, area: Rect) {
let theme = self.ov.theme;
self.hex_area = area;
if let Some(ed) = self.hex.as_mut() {
ed.render(f, area, &theme);
}
}
fn render_schema(&self, f: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for (ty, name, sql) in &self.schema {
lines.push(Line::from(vec![
Span::styled(format!("{:<6}", ty), Style::default().fg(self.ov.theme.alt)),
Span::styled(name.clone(), Style::default().add_modifier(Modifier::BOLD)),
]));
for l in sql.lines() {
lines.push(Line::from(Span::styled(
format!(" {}", l),
Style::default().fg(self.ov.theme.dim),
)));
}
lines.push(Line::from(""));
}
let height = area.height.saturating_sub(2) as usize;
let visible: Vec<Line> = lines
.into_iter()
.skip(self.schema_scroll)
.take(height)
.collect();
f.render_widget(
Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(format!(
" schema — {} objects (j/k scroll · Esc back) ",
self.schema.len()
))),
area,
);
}
fn render_sqlite(&mut self, f: &mut Frame, area: Rect) {
let cols = Layout::horizontal([Constraint::Length(24), Constraint::Min(10)]).split(area);
let (rect_left, rect_right) = (cols[0], cols[1]);
let s = self.sqlite().unwrap();
let visible: Vec<usize> = s
.tables
.iter()
.enumerate()
.filter(|(_, t)| filter_passes(&self.filter, t))
.map(|(i, _)| i)
.collect();
let items: Vec<ListItem> = visible
.iter()
.map(|&i| {
let name = &s.tables[i];
ListItem::new(match s.unreadable_reason(name) {
Some(_) => format!("{name} ⃰"),
None => name.clone(),
})
})
.collect();
let mut lstate = ListState::default();
lstate.select(
visible
.iter()
.position(|&i| i == self.table_idx)
.or(Some(0)),
);
let left_border = self.pane_style(Focus::Left);
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(left_border)
.title(if self.filter.is_empty() {
format!(
" {} — tables ({}) ",
s.path.file_name().and_then(|n| n.to_str()).unwrap_or("db"),
s.tables.len()
)
} else {
format!(
" tables {}/{} /{} ",
visible.len(),
s.tables.len(),
self.filter
)
}),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(list, cols[0], &mut lstate);
let off_left = lstate.selected().map(|_| lstate.offset()).unwrap_or(0);
let mut off_right = 0usize;
let title = match self.current_table() {
Some(t) => {
let (total, exact) = self
.rows
.as_ref()
.map(|r| (r.total, r.total_exact))
.unwrap_or((0, true));
let sorted = match &self.sort {
Some(s) => format!(" — sorted {} {}", s.column, arrow(s.desc)),
None => String::new(),
};
let of = match (exact, self.counting()) {
(true, _) => total.to_string(),
(false, true) => format!("{total}+ …"),
(false, false) => format!("{total}+"),
};
let loading = match (self.loading(), self.searching()) {
(_, true) => " · searching",
(true, false) => " · loading",
(false, false) => "",
};
format!(
" {} — rows {}..{} of {}{}{} ",
t,
self.page_offset,
self.page_offset + self.rows.as_ref().map(|r| r.rows.len() as i64).unwrap_or(0),
of,
sorted,
loading
)
}
None => " (no table) ".into(),
};
if let Some(rv) = &self.rows {
let header = Row::new(
rv.columns
.iter()
.enumerate()
.map(|(i, c)| {
let st = if i == self.col_idx && self.focus == Focus::Right {
Style::default()
.fg(self.ov.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
let label = match &self.sort {
Some(s) if s.column == *c => format!("{} {}", c, arrow(s.desc)),
_ => c.clone(),
};
Cell::from(label).style(st)
})
.collect::<Vec<_>>(),
);
let body = rv.rows.iter().map(|row| {
Row::new(
row.iter()
.map(|c| Cell::from(truncate(c, 40)))
.collect::<Vec<_>>(),
)
});
let widths: Vec<Constraint> =
rv.columns.iter().map(|_| Constraint::Length(20)).collect();
let mut tstate = TableState::default();
tstate.select(Some(self.row_idx));
let table = Table::new(body, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(self.pane_style(Focus::Right))
.title(title),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(table, cols[1], &mut tstate);
off_right = tstate.offset();
} else {
let p = Paragraph::new("no rows").block(
Block::default()
.borders(Borders::ALL)
.border_style(self.pane_style(Focus::Right))
.title(title),
);
f.render_widget(p, cols[1]);
}
self.click_left = rect_left;
self.click_right = rect_right;
self.off_left = off_left;
self.off_right = off_right;
}
fn render_rkyv(&mut self, f: &mut Frame, area: Rect) {
if self.rkyv_view == RkyvView::Records {
self.render_rkyv_records(f, area);
return;
}
let r = match &self.store {
Store::Rkyv(r) => r,
_ => return,
};
match self.rkyv_view {
RkyvView::Info => self.render_rkyv_info(f, area, r),
RkyvView::Strings => self.render_rkyv_strings(f, area),
RkyvView::Hex => self.render_rkyv_hex(f, area, r),
RkyvView::Records => {}
}
}
fn render_rkyv_records(&mut self, f: &mut Frame, area: Rect) {
let cols =
Layout::horizontal([Constraint::Percentage(45), Constraint::Min(10)]).split(area);
self.click_records = cols[0];
let d = match &self.decoded {
Some(d) => d,
None => return,
};
let visible: Vec<usize> = d
.records
.iter()
.enumerate()
.filter(|(_, r)| filter_passes(&self.filter, &r.key))
.map(|(i, _)| i)
.collect();
let items: Vec<ListItem> = visible
.iter()
.map(|&i| ListItem::new(truncate(&d.records[i].key, 60)))
.collect();
let mut st = ListState::default();
st.select(
visible
.iter()
.position(|&i| i == self.record_idx)
.or(Some(0)),
);
let title = if self.filter.is_empty() {
format!(" {} — {} keys ", d.format, d.records.len())
} else {
format!(
" {} — {}/{} keys /{} ",
d.format,
visible.len(),
d.records.len(),
self.filter
)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.ov.theme.accent))
.title(title),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(list, cols[0], &mut st);
let off_records = st.offset();
let mut lines: Vec<Line> = Vec::new();
if let Some(rec) = d.records.get(self.record_idx) {
for (name, val) in &rec.fields {
lines.push(Line::from(vec![
Span::styled(
format!("{:<22}", name),
Style::default().fg(self.ov.theme.dim),
),
Span::raw(val.clone()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("value — {} bytes (hex):", rec.value.len()),
Style::default().fg(self.ov.theme.primary),
)));
let rows = area.height.saturating_sub(6) as usize;
for i in 0..rows {
let off = i * 16;
if off >= rec.value.len() {
break;
}
lines.push(Line::from(hexedit::hex_dump_line(
off,
&rec.value[off.min(rec.value.len())..(off + 16).min(rec.value.len())],
)));
}
}
let p =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" value "));
f.render_widget(p, cols[1]);
self.off_records = off_records;
}
fn render_rkyv_info(&self, f: &mut Frame, area: Rect, r: &RkyvStore) {
let mut lines = vec![
Line::from(vec![
Span::styled("file: ", Style::default().fg(self.ov.theme.dim)),
Span::raw(r.path.display().to_string()),
]),
Line::from(vec![
Span::styled("size: ", Style::default().fg(self.ov.theme.dim)),
Span::raw(format!("{} bytes", r.len())),
]),
Line::from(vec![
Span::styled("strings: ", Style::default().fg(self.ov.theme.dim)),
Span::raw(if self.strings_truncated {
format!(
"{} runs (>= {} printable bytes) — capped, scanned first {}",
self.strings.len(),
MIN_STRING,
human_size(self.strings_scanned as u64)
)
} else {
format!(
"{} runs (>= {} printable bytes)",
self.strings.len(),
MIN_STRING
)
}),
]),
];
match &self.decoded {
Some(d) => {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("format: ", Style::default().fg(self.ov.theme.dim)),
Span::styled(d.format.clone(), Style::default().fg(self.ov.theme.label)),
]));
lines.push(Line::from(vec![
Span::styled("records: ", Style::default().fg(self.ov.theme.dim)),
Span::raw(d.records.len().to_string()),
]));
lines.push(Line::from(""));
for (name, val) in &d.header {
lines.push(Line::from(vec![
Span::styled(
format!(" {:<16}", name),
Style::default().fg(self.ov.theme.dim),
),
Span::raw(val.clone()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Views: 0 Records (key/value) 2 Strings 3 Hex",
Style::default().fg(self.ov.theme.dim),
)));
}
None => {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"unrecognized rkyv archive: no matching format decoder.",
Style::default().fg(self.ov.theme.primary),
)));
lines.push(Line::from(Span::styled(
"rkyv stores no field names or type tags, so an unknown type",
Style::default().fg(self.ov.theme.primary),
)));
lines.push(Line::from(Span::styled(
"cannot be decoded generically — showing raw structure.",
Style::default().fg(self.ov.theme.primary),
)));
lines.push(Line::from(Span::styled(
"Views: 2 Strings (embedded text) 3 Hex (raw bytes)",
Style::default().fg(self.ov.theme.dim),
)));
}
}
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title(" rkyv / binary — Info "),
);
f.render_widget(p, area);
}
fn render_rkyv_strings(&self, f: &mut Frame, area: Rect) {
let visible = self.visible_strings();
let items: Vec<ListItem> = visible
.iter()
.map(|&i| {
let h = &self.strings[i];
ListItem::new(Line::from(vec![
Span::styled(
format!("{:08x} ", h.offset),
Style::default().fg(self.ov.theme.dim),
),
Span::raw(truncate(&h.text, 200)),
]))
})
.collect();
let mut st = ListState::default();
st.select(
visible
.iter()
.position(|&i| i == self.string_idx)
.or(Some(0)),
);
let capped = if self.strings_truncated { "+" } else { "" };
let title = if self.filter.is_empty() {
format!(" Strings ({}{}) ", self.strings.len(), capped)
} else {
format!(
" Strings ({}/{}{}) /{} ",
visible.len(),
self.strings.len(),
capped,
self.filter
)
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(list, area, &mut st);
}
fn render_rkyv_hex(&self, f: &mut Frame, area: Rect, r: &RkyvStore) {
let rows_visible = area.height.saturating_sub(2) as usize;
let start = self.hex_row * 16;
let mut lines = Vec::new();
for i in 0..rows_visible {
let off = start + i * 16;
if off >= r.len() {
break;
}
lines.push(Line::from(r.hex_row(off)));
}
let p = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(format!(
" Hex — offset {:#x} / {} bytes ",
start,
r.len()
)));
f.render_widget(p, area);
}
fn render_status(&self, f: &mut Frame, area: Rect) {
let p = Paragraph::new(self.status.clone())
.style(Style::default().fg(Color::Black).bg(Color::Gray));
f.render_widget(p, area);
}
fn render_input(&self, f: &mut Frame, title: &str, buf: &str) {
let area = centered(f.area(), 60, 3);
f.render_widget(Clear, area);
let cur = self.input_cursor.min(buf.len());
let (pre, rest) = buf.split_at(cur);
let (at, post) = match rest.char_indices().nth(1) {
Some((i, _)) => rest.split_at(i),
None => (rest, ""),
};
let at_disp = if at.is_empty() { " " } else { at };
let line = Line::from(vec![
Span::raw(pre.to_string()),
Span::styled(
at_disp.to_string(),
Style::default().add_modifier(Modifier::REVERSED),
),
Span::raw(post.to_string()),
]);
let p = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.ov.theme.accent))
.title(format!(" {} ", title)),
);
f.render_widget(p, area);
}
fn pane_style(&self, which: Focus) -> Style {
if self.focus == which {
Style::default().fg(self.ov.theme.accent)
} else {
Style::default().fg(self.ov.theme.dim)
}
}
}
pub struct Choice {
pub path: PathBuf,
pub kind: Kind,
pub opened: Option<std::time::SystemTime>,
pub format: Option<&'static str>,
pub size: Option<u64>,
pub modified: Option<std::time::SystemTime>,
rank: u8,
}
impl Choice {
fn from_entry(e: &Entry) -> Self {
Choice {
path: e.path.clone(),
kind: e.kind,
opened: Some(e.opened),
format: None,
size: None,
modified: None,
rank: 0,
}
}
fn from_hit(h: crate::scan::Hit) -> Self {
Choice {
path: h.path,
kind: h.kind,
opened: None,
format: h.format,
size: Some(h.size),
modified: Some(h.modified),
rank: h.rank,
}
}
fn name(&self) -> &str {
self.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
}
}
pub struct Picked {
pub path: PathBuf,
pub theme: Theme,
}
fn merge_hits(choices: &mut Vec<Choice>, hits: Vec<crate::scan::Hit>) {
for hit in hits {
let dup = choices.iter().any(|c| {
c.path == hit.path
|| (c.path.canonicalize().ok() == hit.path.canonicalize().ok()
&& c.path.canonicalize().is_ok())
});
if !dup {
choices.push(Choice::from_hit(hit));
}
}
let first_scanned = choices
.iter()
.position(|c| c.opened.is_none())
.unwrap_or(choices.len());
choices[first_scanned..].sort_by_key(|c| {
(
c.rank,
std::cmp::Reverse(c.modified),
c.path.components().count(),
)
});
}
pub struct Picker<'a> {
pub recent: &'a [Entry],
pub theme: Theme,
pub cached: Vec<crate::scan::Hit>,
pub cache_age: Option<std::time::Duration>,
pub scan: Option<crate::scan::Scan>,
pub roots: Vec<crate::scan::Root>,
pub persist: bool,
}
pub fn pick_mru(terminal: &mut DefaultTerminal, mut p: Picker<'_>) -> Result<Option<Picked>> {
let entries = p.recent;
let mut scan = p.scan.take();
let mut cache_age = p.cache_age;
let mut scanned: Vec<crate::scan::Hit> = std::mem::take(&mut p.cached);
let mut choices: Vec<Choice> = entries.iter().map(Choice::from_entry).collect();
merge_hits(&mut choices, scanned.clone());
let mut filter = String::new();
let mut typing = false;
let mut sel = 0usize;
let mut pending_g = false;
let mut before_filter = 0usize;
let mut list_offset = 0usize;
let mut monitor: Option<crate::monitor::Monitor> = None;
let mut ov = Overlays::new(p.theme);
loop {
let scanning = match scan.as_mut() {
Some(sc) => {
let hits = sc.drain();
scanned.extend(hits.iter().cloned());
merge_hits(&mut choices, hits);
if sc.running {
Some(sc.found)
} else {
if p.persist {
crate::scan::save_cache(&scanned);
cache_age = Some(std::time::Duration::ZERO);
}
scan = None;
None
}
}
None => None,
};
let view: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| filter_passes(&filter, c.path.to_str().unwrap_or("")))
.map(|(i, _)| i)
.collect();
sel = sel.min(view.len().saturating_sub(1));
let page = terminal
.size()
.map(|s| s.height.saturating_sub(3) as usize)
.unwrap_or(10)
.max(1);
let prompt = typing.then_some(filter.as_str());
if let Some(m) = monitor.as_mut() {
m.tick();
}
let ctx = if monitor.is_some() {
HelpCtx::Top
} else {
HelpCtx::Picker
};
terminal.draw(|f| {
match monitor.as_ref() {
Some(m) => m.render(f, f.area(), &ov.theme),
None => {
list_offset = render_picker(
f, &choices, &view, sel, &filter, prompt, scanning, cache_age, &ov.theme,
);
}
}
ov.render(f, ctx);
})?;
if !event::poll(TICK)? {
ov.expire_toast();
continue;
}
let ev = event::read()?;
ov.expire_toast();
if let Some(m) = monitor.as_mut() {
match ev {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if ov.on_key(key.code) {
continue;
}
let size = terminal.size().unwrap_or_default();
let page = crate::monitor::Monitor::page_rows(Rect::new(
0,
0,
size.width,
size.height,
));
let action = m.on_key(key.code, page);
if let Some(note) = m.note.take() {
ov.toast(note);
}
match action {
crate::monitor::Action::None => {}
crate::monitor::Action::Back => monitor = None,
crate::monitor::Action::Quit => return Ok(None),
crate::monitor::Action::Open(path) => {
if let Some(sc) = &scan {
sc.cancel();
}
return Ok(Some(Picked {
path,
theme: ov.theme,
}));
}
crate::monitor::Action::Frames(path) => {
if let Some(sc) = &scan {
sc.cancel();
}
return Ok(Some(Picked {
path,
theme: ov.theme,
}));
}
}
}
Event::Mouse(mouse) if !ov.on_mouse(mouse) => {
let size = terminal.size().unwrap_or_default();
m.on_mouse(mouse, Rect::new(0, 0, size.width, size.height));
if let Some(note) = m.note.take() {
ov.toast(note);
}
}
_ => {}
}
continue;
}
let last = view.len().saturating_sub(1);
let pick = |i: usize| -> Option<PathBuf> { view.get(i).map(|&c| choices[c].path.clone()) };
if let Event::Mouse(m) = ev {
if ov.on_mouse(m) {
continue;
}
match m.kind {
MouseEventKind::ScrollDown => sel = (sel + 1).min(last),
MouseEventKind::ScrollUp => sel = sel.saturating_sub(1),
MouseEventKind::Down(_) => {
let row = (m.row as usize).saturating_sub(1);
let clicked = row + list_offset;
if row < page && clicked < view.len() {
if let Some(sc) = &scan {
sc.cancel();
}
return Ok(pick(clicked).map(|path| Picked {
path,
theme: ov.theme,
}));
}
}
_ => {}
}
continue;
}
if let Event::Key(key) = ev {
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('f') => sel = (sel + page).min(last),
KeyCode::Char('b') => sel = sel.saturating_sub(page),
_ => {}
}
continue;
}
if typing {
match filter_prompt_key(key.code, &mut filter, &mut sel, last, page) {
Prompt::Open => {}
Prompt::Accept => typing = false,
Prompt::Cancel => {
typing = false;
filter.clear();
sel = before_filter;
}
}
continue;
}
if ov.on_key(key.code) {
continue;
}
if key.code == KeyCode::Char('g') {
if pending_g {
pending_g = false;
sel = 0;
} else {
pending_g = true;
}
continue;
}
pending_g = false;
match key.code {
KeyCode::Esc if !filter.is_empty() => {
filter.clear();
sel = 0;
}
KeyCode::Char('q') | KeyCode::Esc => {
if let Some(sc) = &scan {
sc.cancel();
}
return Ok(None);
}
KeyCode::Char('/') => {
typing = true;
filter.clear();
before_filter = sel;
sel = 0;
}
KeyCode::Char('w') => {
let mut targets: Vec<(PathBuf, Kind)> = view
.iter()
.map(|&i| (choices[i].path.clone(), choices[i].kind))
.collect();
targets.extend(watch_targets());
let m = crate::monitor::Monitor::new(targets);
if m.is_empty() {
ov.toast("nothing to watch yet");
} else {
ov.toast(format!("watching {} files for writes", m.len()));
monitor = Some(m);
}
}
KeyCode::Char('n') => sel = (sel + 1).min(last),
KeyCode::Char('N') => sel = sel.saturating_sub(1),
KeyCode::Char('r') | KeyCode::Char('R') => {
if key.code == KeyCode::Char('R') {
crate::scan::clear_cache();
}
if let Some(sc) = &scan {
sc.cancel();
}
scanned.clear();
choices.retain(|c| c.opened.is_some());
sel = 0;
cache_age = None;
scan = Some(crate::scan::spawn(p.roots.clone()));
ov.toast("rescanning");
}
KeyCode::Char('G') => sel = last,
KeyCode::PageDown => sel = (sel + page).min(last),
KeyCode::PageUp => sel = sel.saturating_sub(page),
KeyCode::Up | KeyCode::Char('k') => sel = sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => sel = (sel + 1).min(last),
KeyCode::Enter => {
if let Some(path) = pick(sel) {
if let Some(sc) = &scan {
sc.cancel();
}
return Ok(Some(Picked {
path,
theme: ov.theme,
}));
}
}
_ => {}
}
}
}
}
const DECODE_INLINE_MAX: usize = 4 * 1024 * 1024;
fn append_to(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
f.write_all(bytes)
}
type StatsResult = std::result::Result<Vec<crate::sqlite::ColumnStat>, String>;
fn spawn_stats(path: std::path::PathBuf, table: String) -> std::sync::mpsc::Receiver<StatsResult> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = SqliteStore::open(&path)
.and_then(|s| s.column_stats(&table))
.map_err(|e| e.to_string());
let _ = tx.send(result);
});
rx
}
fn spawn_decode(bytes: std::sync::Arc<[u8]>) -> std::sync::mpsc::Receiver<Option<Decoded>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(formats::try_decode(&bytes));
});
rx
}
#[derive(Debug, PartialEq, Eq)]
pub enum Prompt {
Open,
Accept,
Cancel,
}
pub fn filter_prompt_key(
code: KeyCode,
filter: &mut String,
sel: &mut usize,
last: usize,
page: usize,
) -> Prompt {
match code {
KeyCode::Esc => return Prompt::Cancel,
KeyCode::Enter => return Prompt::Accept,
KeyCode::Backspace => {
filter.pop();
*sel = 0;
}
KeyCode::Up => *sel = sel.saturating_sub(1),
KeyCode::Down => *sel = (*sel + 1).min(last),
KeyCode::PageUp => *sel = sel.saturating_sub(page),
KeyCode::PageDown => *sel = (*sel + page).min(last),
KeyCode::Home => *sel = 0,
KeyCode::End => *sel = last,
KeyCode::Char(c) => {
filter.push(c);
*sel = 0;
}
_ => {}
}
Prompt::Open
}
#[allow(clippy::too_many_arguments)]
fn render_picker(
f: &mut Frame,
choices: &[Choice],
view: &[usize],
sel: usize,
filter: &str,
prompt: Option<&str>,
scanning: Option<usize>,
cache_age: Option<std::time::Duration>,
t: &Theme,
) -> usize {
let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
let mut offset = 0usize;
if view.is_empty() {
let body = if !filter.is_empty() {
vec![
Line::from(""),
Line::from(format!(" Nothing matches /{}", filter)),
Line::from(""),
Line::from(Span::styled(
" Esc clears the filter",
Style::default().fg(t.dim),
)),
]
} else if scanning.is_some() {
vec![
Line::from(""),
Line::from(" Scanning for databases and rkyv shards…"),
]
} else {
vec![
Line::from(""),
Line::from(" Nothing found."),
Line::from(""),
Line::from(Span::styled(
" Open one with: zdbview <file>",
Style::default().fg(t.dim),
)),
Line::from(Span::styled(
" Or scan elsewhere: zdbview --scan <dir>",
Style::default().fg(t.dim),
)),
]
};
let p = Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.accent))
.title(" zdbview — files "),
);
f.render_widget(p, outer[0]);
} else {
let items: Vec<ListItem> = view
.iter()
.map(|&i| {
let c = &choices[i];
let dir = c.path.parent().and_then(|p| p.to_str()).unwrap_or("");
let (badge, color) = match c.kind {
Kind::Sqlite => ("sqlite", t.primary),
Kind::Rkyv => ("rkyv ", t.alt),
};
let (age, age_style) = match (c.opened, c.size) {
(Some(when), _) => (mru::rel_age(when), Style::default().fg(t.dim)),
(None, Some(size)) => (human_size(size), Style::default().fg(t.label)),
(None, None) => ("found".to_string(), Style::default().fg(t.label)),
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", badge), Style::default().fg(color)),
Span::styled(
format!("{:<28}", truncate(c.name(), 28)),
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>10} ", age), age_style),
Span::styled(truncate(dir, 52), Style::default().fg(t.label)),
]))
})
.collect();
let mut st = ListState::default();
st.select(Some(sel.min(view.len() - 1)));
let recent = view
.iter()
.filter(|&&i| choices[i].opened.is_some())
.count();
let title = if !filter.is_empty() {
format!(
" zdbview — {}/{} files /{} ",
view.len(),
choices.len(),
filter
)
} else {
match (scanning, cache_age) {
(Some(found), _) => format!(
" zdbview — {} files ({} recent, scanning… {} found) ",
view.len(),
recent,
found
),
(None, Some(age)) => format!(
" zdbview — {} files ({} recent, scan {} · r rescans) ",
view.len(),
recent,
age_label(age)
),
(None, None) => format!(" zdbview — {} files ({} recent) ", view.len(), recent),
}
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.accent))
.title(title),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(list, outer[0], &mut st);
offset = st.offset();
}
let help = match prompt {
Some(q) => {
Paragraph::new(format!("/{}_", q)).style(Style::default().fg(Color::Black).bg(t.accent))
}
None => {
let detail = view
.get(sel)
.and_then(|&i| choices[i].format)
.map(|fmt| format!(" · {}", fmt))
.unwrap_or_default();
Paragraph::new(format!(
"j/k move · / filter · Enter open · c scheme · h help · q quit{}",
detail
))
.style(Style::default().fg(Color::Black).bg(t.help_key))
}
};
f.render_widget(help, outer[1]);
offset
}
pub fn watch_targets() -> Vec<(PathBuf, Kind)> {
let mut targets: Vec<(PathBuf, Kind)> = crate::mru::load()
.into_iter()
.map(|e| (e.path, e.kind))
.collect();
if let Some(c) = crate::scan::load_cache() {
targets.extend(c.hits.into_iter().map(|h| (h.path, h.kind)));
}
targets.retain(|(p, _)| p.is_file());
targets
}
pub fn resolve_theme(theme_override: Option<ThemeName>) -> Theme {
let prefs = crate::prefs::load();
match (theme_override, prefs.custom) {
(Some(name), _) => Theme::from_name(name),
(None, Some(c)) => Theme::from_palette(prefs.theme, c),
(None, None) => Theme::from_name(prefs.theme),
}
}
fn filter_passes(filter: &str, hay: &str) -> bool {
filter.is_empty() || hay.to_lowercase().contains(&filter.to_lowercase())
}
fn find_next_visible(
visible: &[usize],
from: usize,
forward: bool,
pred: impl Fn(usize) -> bool,
) -> Option<usize> {
if visible.is_empty() {
return None;
}
let pos = match visible.binary_search(&from) {
Ok(p) => p,
Err(p) if forward => p.saturating_sub(1),
Err(p) => p.min(visible.len() - 1),
};
find_next(visible.len(), pos, forward, |i| pred(visible[i])).map(|i| visible[i])
}
fn find_next(
len: usize,
from: usize,
forward: bool,
pred: impl Fn(usize) -> bool,
) -> Option<usize> {
if len == 0 {
return None;
}
for step in 1..=len {
let i = if forward {
(from + step) % len
} else {
(from + len - (step % len)) % len
};
if pred(i) {
return Some(i);
}
}
None
}
fn find_bytes(hay: &[u8], needle: &[u8], cur: usize, forward: bool) -> Option<usize> {
if needle.is_empty() || hay.len() < needle.len() {
return None;
}
let last = hay.len() - needle.len();
if forward {
let start = (cur + 1).min(last + 1);
(start..=last).find(|&i| &hay[i..i + needle.len()] == needle)
} else {
let start = cur.min(last + 1);
(0..start)
.rev()
.find(|&i| &hay[i..i + needle.len()] == needle)
}
}
fn hit(r: Rect, col: u16, row: u16) -> bool {
col >= r.x
&& col < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
fn input_left(buf: &str, cur: usize) -> usize {
if cur > 0 {
buf[..cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
} else {
0
}
}
fn input_right(buf: &str, cur: usize) -> usize {
if cur < buf.len() {
buf[cur..]
.char_indices()
.nth(1)
.map(|(i, _)| cur + i)
.unwrap_or(buf.len())
} else {
buf.len()
}
}
fn input_delete_word(buf: &mut String, cur: usize) -> usize {
let s = &buf[..cur];
let trimmed = s.trim_end();
let word_start = match trimmed
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
{
Some((i, c)) => i + c.len_utf8(),
None => 0,
};
buf.drain(word_start..cur);
word_start
}
fn looks_textual(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return true;
}
if std::str::from_utf8(bytes).is_err() {
return false;
}
let ctrl = bytes
.iter()
.filter(|&&b| b < 0x09 || (0x0e..0x20).contains(&b))
.count();
ctrl * 10 < bytes.len()
}
fn hex_string(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn sanitize(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn value_lines(
bytes: &[u8],
render: ValueRender,
scroll: usize,
height: usize,
) -> Vec<Line<'static>> {
if render == ValueRender::Disasm {
return disasm_lines(bytes, scroll, height);
}
let as_text = match render {
ValueRender::Text => true,
ValueRender::Hex => false,
ValueRender::Auto => looks_textual(bytes),
ValueRender::Disasm => unreachable!(),
};
let mut lines = Vec::new();
if as_text {
let text = String::from_utf8_lossy(bytes);
for line in text.lines().skip(scroll).take(height) {
lines.push(Line::from(line.to_string()));
}
} else {
for i in 0..height {
let off = (scroll + i) * 16;
if off >= bytes.len() {
break;
}
lines.push(Line::from(hexedit::hex_dump_line(
off,
&bytes[off.min(bytes.len())..(off + 16).min(bytes.len())],
)));
}
}
lines
}
#[cfg(feature = "disasm")]
fn disasm_lines(bytes: &[u8], scroll: usize, height: usize) -> Vec<Line<'static>> {
match crate::disasm::disassemble(bytes) {
Ok(all) => all
.into_iter()
.skip(scroll)
.take(height)
.map(Line::from)
.collect(),
Err(e) => vec![Line::from(format!("not a fusevm chunk: {e}"))],
}
}
#[cfg(not(feature = "disasm"))]
fn disasm_lines(_bytes: &[u8], _scroll: usize, _height: usize) -> Vec<Line<'static>> {
vec![Line::from(
"rebuild with `--features disasm` for bytecode disassembly",
)]
}
fn age_label(age: std::time::Duration) -> String {
let secs = age.as_secs();
match secs {
0..=90 => "just now".to_string(),
_ if secs < 3600 => format!("{}m old", secs / 60),
_ if secs < 86_400 => format!("{}h old", secs / 3600),
_ => format!("{}d old", secs / 86_400),
}
}
pub fn human_size(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut size = n as f64;
let mut unit = 0;
while size >= 1024.0 && unit + 1 < UNITS.len() {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", n, UNITS[0])
} else if size < 10.0 {
format!("{:.1} {}", size, UNITS[unit])
} else {
format!("{:.0} {}", size, UNITS[unit])
}
}
fn arrow(desc: bool) -> &'static str {
if desc {
"▼"
} else {
"▲"
}
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn centered(area: Rect, w: u16, h: u16) -> Rect {
let w = w.min(area.width);
let h = h.min(area.height);
Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::{
find_bytes, find_next, hit, input_delete_word, input_left, input_right, App, Store,
};
use crate::mru::Entry;
use crate::overlay::HelpCtx;
use crate::rkyv_inspect::RkyvStore;
use crate::sqlite::SqliteStore;
use crate::store::Kind;
use crate::theme::{Theme, ThemeName};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
fn scratch(ext: &str) -> std::path::PathBuf {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!(
"zdbview_app_{}_{}.{}",
std::process::id(),
seq,
ext
));
let _ = std::fs::remove_file(&path);
path
}
fn rkyv_app() -> App {
let path = scratch("bin");
std::fs::write(&path, b"zdbview overlay render test payload").unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let _ = std::fs::remove_file(&path);
App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl))
}
fn sqlite_app_rows(n: usize) -> (App, std::path::PathBuf) {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute("CREATE TABLE t (a TEXT, b TEXT)", []).unwrap();
for i in 0..n {
conn.execute("INSERT INTO t VALUES (?1, 'y')", [i.to_string()])
.unwrap();
}
drop(conn);
let store = Store::Sqlite(SqliteStore::open(&path).unwrap());
(
App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl)),
path,
)
}
fn rkyv_app_with(bytes: &[u8]) -> App {
let path = scratch("bin");
std::fs::write(&path, bytes).unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let _ = std::fs::remove_file(&path);
App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl))
}
fn sqlite_app() -> (App, std::path::PathBuf) {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute("CREATE TABLE t (a TEXT, b TEXT, c TEXT)", [])
.unwrap();
conn.execute("INSERT INTO t VALUES ('x', 'y', 'z')", [])
.unwrap();
drop(conn);
let store = Store::Sqlite(SqliteStore::open(&path).unwrap());
(
App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl)),
path,
)
}
fn frame_rows(app: &mut App, w: u16, h: u16) -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| app.render(f)).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
fn contains(rows: &[String], needle: &str) -> bool {
rows.iter().any(|r| r.contains(needle))
}
fn press(app: &mut App, c: char) {
app.on_key(KeyEvent::from(KeyCode::Char(c)));
}
fn await_queries(app: &mut App) {
for _ in 0..5000 {
app.poll_query();
let busy = app
.engine
.as_ref()
.is_some_and(|e| e.page_inflight() || e.count_inflight() || e.searching());
if !busy {
return;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
panic!("a query never finished");
}
fn await_stats(app: &mut App) {
for _ in 0..2000 {
app.poll_stats();
if app.stats_pending.is_none() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
panic!("the statistics pass never finished");
}
#[test]
fn the_sql_editor_runs_dot_commands() {
use crate::sqledit::Entry;
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE t (a TEXT, b INTEGER);
CREATE INDEX t_a ON t(a);
INSERT INTO t VALUES ('x', 1), ('y', 2);",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
press(&mut app, ':');
let note = |app: &App| -> Vec<String> {
app.sql
.as_ref()
.unwrap()
.transcript()
.iter()
.rev()
.find_map(|e| match e {
Entry::Note(lines) => Some(lines.clone()),
_ => None,
})
.expect("a note")
};
app.execute_sql(".tables");
assert_eq!(note(&app), vec!["t".to_string()]);
app.execute_sql(".indexes t");
assert!(note(&app)[0].starts_with("t_a (a)"), "{:?}", note(&app));
app.execute_sql(".help");
assert!(
note(&app).iter().any(|l| l.starts_with(".expert")),
"the help lists every command it implements"
);
app.execute_sql(".mode csv");
app.execute_sql("SELECT a, b FROM t ORDER BY a");
assert_eq!(note(&app), vec!["a,b", "x,1", "y,2"]);
app.execute_sql(".headers off");
app.execute_sql("SELECT a, b FROM t ORDER BY a");
assert_eq!(
note(&app),
vec!["x,1", "y,2"],
"headers off drops the names"
);
app.execute_sql(".mode list");
app.execute_sql("SELECT a FROM t");
assert!(
matches!(
app.sql.as_ref().unwrap().transcript().iter().rev().nth(1),
Some(Entry::Rows { .. })
),
"list mode is the grid again"
);
let out = scratch("csv");
app.execute_sql(".mode csv");
app.execute_sql(".headers on"); app.execute_sql(&format!(".once {}", out.display()));
app.execute_sql("SELECT a FROM t ORDER BY a");
assert!(note(&app)[0].contains("wrote 2 rows"), "{:?}", note(&app));
assert_eq!(std::fs::read_to_string(&out).unwrap(), "a\nx\ny\n");
assert!(
app.sql_out.is_none(),
"a once redirect ends after one result"
);
let all = scratch("all.csv");
app.execute_sql(&format!(".output {}", all.display()));
app.execute_sql("SELECT a FROM t WHERE a = 'x'");
app.execute_sql("SELECT a FROM t WHERE a = 'y'");
let collected = std::fs::read_to_string(&all).unwrap();
assert_eq!(
collected, "a\nx\na\ny\n",
"both results are in the file, in order"
);
app.execute_sql(&format!(".output {}", all.display()));
app.execute_sql("SELECT a FROM t WHERE a = 'x'");
assert_eq!(std::fs::read_to_string(&all).unwrap(), "a\nx\n");
app.execute_sql(".output");
assert!(
app.sql_out.is_none(),
"bare .output goes back to the transcript"
);
let _ = std::fs::remove_file(&all);
let csv = scratch("in.csv");
std::fs::write(&csv, "a,b\nz,3\n").unwrap();
app.execute_sql(&format!(".import {} t", csv.display()));
assert_eq!(note(&app), vec!["imported 1 rows into t".to_string()]);
let script = scratch("sql");
std::fs::write(
&script,
"UPDATE t SET b = 9 WHERE a = 'z';\nSELECT count(*) FROM t;",
)
.unwrap();
app.execute_sql(&format!(".read {}", script.display()));
assert!(
note(&app).iter().any(|l| l.contains("ran 2 statements")),
"{:?}",
note(&app)
);
app.execute_sql(".mode list");
for c in "SELECT a FROM t WHERE b = 1".chars() {
app.on_key(KeyEvent::from(KeyCode::Char(c)));
}
app.on_key(KeyEvent::from(KeyCode::Enter));
app.execute_sql(".expert");
let advice = note(&app);
assert!(
advice[0].contains("full scan") && advice[0].contains("CREATE INDEX"),
"{advice:?}"
);
assert!(
advice[0].contains("\"b\""),
"the column compared: {advice:?}"
);
app.execute_sql(".nope");
assert!(
note(&app)[0].contains("unknown command"),
"{:?}",
note(&app)
);
for f in [path, out, csv, script] {
let _ = std::fs::remove_file(f);
}
}
#[test]
fn the_monitor_opens_the_table_pane_and_the_log_walker() {
let path = scratch("db");
for suffix in ["-wal", "-shm"] {
let mut n = path.as_os_str().to_os_string();
n.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(n));
}
let conn = rusqlite::Connection::open(&path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", 0).unwrap();
conn.execute_batch("CREATE TABLE t (v TEXT)").unwrap();
for i in 0..120 {
conn.execute("INSERT INTO t VALUES (?1)", [format!("row {i} padded out")])
.unwrap();
}
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
press(&mut app, 'w'); assert_eq!(app.screen, super::Screen::Top);
app.top = Some(crate::monitor::Monitor::new([(path.clone(), Kind::Sqlite)]));
if let Some(m) = app.top.as_mut() {
m.watcher.interval = std::time::Duration::from_millis(0);
m.tick();
m.tick();
}
press(&mut app, 't');
assert_eq!(
app.top.as_ref().unwrap().pane,
crate::monitor::Pane::Tables,
"t swaps the bottom pane"
);
let rows = frame_rows(&mut app, 120, 30);
assert!(contains(&rows, "tables —"), "{:?}", rows.last());
assert!(
contains(&rows, " t ") || contains(&rows, "sqlite_schema"),
"the pane names what was written: {rows:?}"
);
press(&mut app, 'F');
assert_eq!(app.screen, super::Screen::Frames);
let walk = app.walk.as_ref().expect("a log walker");
assert!(!walk.frames.is_empty());
assert_eq!(app.help_ctx(), HelpCtx::Frames);
let rows = frame_rows(&mut app, 120, 30);
assert!(contains(&rows, "what this frame wrote"), "{:?}", rows[0]);
assert!(
contains(&rows, "commits"),
"the frame list is titled with its commit count"
);
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.screen, super::Screen::Top);
assert!(app.walk.is_none());
drop(conn);
for suffix in ["", "-wal", "-shm"] {
let mut n = path.as_os_str().to_os_string();
n.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(n));
}
}
#[test]
fn f_follows_a_foreign_key_to_its_parent_row() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"PRAGMA foreign_keys=OFF;
CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE book (title TEXT, author_id INTEGER REFERENCES author(id));
INSERT INTO author VALUES (1, 'first'), (2, 'second'), (3, 'third');
INSERT INTO book VALUES ('a book', 3), ('orphan', 99), ('unknown', NULL);",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
let book = app
.sqlite()
.unwrap()
.tables
.iter()
.position(|t| t == "book")
.unwrap();
app.select_table(book);
app.on_key(KeyEvent::from(KeyCode::Tab));
app.on_key(KeyEvent::from(KeyCode::Right));
press(&mut app, 'F');
assert_eq!(
app.current_table().as_deref(),
Some("author"),
"it jumps to the parent table"
);
let row = &app.rows.as_ref().unwrap().rows[app.row_idx];
assert_eq!(row[1], "third", "and lands on the referenced row");
assert_eq!(
app.rows.as_ref().unwrap().columns[app.col_idx],
"id",
"with the cursor on the key column"
);
app.select_table(book);
app.on_key(KeyEvent::from(KeyCode::Right));
app.on_key(KeyEvent::from(KeyCode::Down)); press(&mut app, 'F');
assert_eq!(app.current_table().as_deref(), Some("book"));
assert!(app.status.contains("no author row"), "got {:?}", app.status);
app.on_key(KeyEvent::from(KeyCode::Down)); press(&mut app, 'F');
assert!(app.status.contains("NULL"), "got {:?}", app.status);
app.on_key(KeyEvent::from(KeyCode::Left)); press(&mut app, 'F');
assert!(
app.status.contains("not a foreign key"),
"got {:?}",
app.status
);
let _ = std::fs::remove_file(path);
}
#[test]
fn y_copies_the_row_as_an_insert_statement() {
let (mut app, path) = sqlite_app();
app.on_key(KeyEvent::from(KeyCode::Tab)); press(&mut app, 'Y');
assert!(
app.status.contains("copied INSERT") || app.status.contains("clipboard unavailable"),
"got {:?}",
app.status
);
let view = app.rows.as_ref().unwrap();
let sql = crate::export::insert_statement("t", &view.columns, &view.rows[0]);
assert_eq!(
sql,
r#"INSERT INTO "t" ("a", "b", "c") VALUES ('x', 'y', 'z');"#
);
let _ = std::fs::remove_file(path);
}
#[test]
fn the_detail_screen_edits_the_field_it_is_showing() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (a TEXT, b TEXT)")
.unwrap();
conn.execute("INSERT INTO t VALUES ('one', 'two')", [])
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab)); app.on_key(KeyEvent::from(KeyCode::Enter)); assert_eq!(app.screen, super::Screen::Detail);
assert_eq!(app.detail_value, b"one");
app.on_key(KeyEvent::from(KeyCode::Right));
assert_eq!(app.col_idx, 1);
assert_eq!(app.detail_value, b"two");
press(&mut app, 'e');
assert!(matches!(app.mode, super::Mode::EditCell(_)));
assert_eq!(app.screen, super::Screen::Detail);
app.on_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
for c in "TWO".chars() {
app.on_key(KeyEvent::from(KeyCode::Char(c)));
}
app.on_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
app.detail_value, b"TWO",
"the value pane must show what was just written"
);
let store = SqliteStore::open(&path).unwrap();
let view = store.rows(&crate::sqlite::PageQuery::all("t", 10)).unwrap();
assert_eq!(view.rows[0], vec!["one".to_string(), "TWO".to_string()]);
let _ = std::fs::remove_file(path);
}
#[test]
fn shift_e_turns_a_text_or_null_cell_into_bytes() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE t (txt TEXT, empty BLOB);
INSERT INTO t VALUES ('hi', NULL);",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab));
press(&mut app, 'e');
assert!(matches!(app.mode, super::Mode::EditCell(_)));
app.on_key(KeyEvent::from(KeyCode::Esc));
press(&mut app, 'E');
assert_eq!(app.screen, super::Screen::HexEdit);
assert_eq!(
app.hex.as_ref().unwrap().bytes,
b"hi",
"a string starts as its own bytes"
);
press(&mut app, 'i');
press(&mut app, '4');
press(&mut app, '1');
app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
let store = SqliteStore::open(&path).unwrap();
let key = crate::sqlite::RowKey::Rowid(1);
assert_eq!(store.cell_bytes_keyed("t", &key, "txt").unwrap(), b"Ai");
assert!(
store.cell_is_blob_keyed("t", &key, "txt").unwrap(),
"it is a blob now, not a string"
);
app.on_key(KeyEvent::from(KeyCode::Esc));
press(&mut app, 'q');
app.on_key(KeyEvent::from(KeyCode::Right));
press(&mut app, 'E');
assert_eq!(app.screen, super::Screen::HexEdit);
assert!(
app.hex.as_ref().unwrap().bytes.is_empty(),
"NULL has no bytes"
);
press(&mut app, 'o'); app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
let store = SqliteStore::open(&path).unwrap();
assert!(
store.cell_is_blob_keyed("t", &key, "empty").unwrap(),
"a NULL became a blob"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_blob_edited_from_the_detail_screen_returns_there() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (raw BLOB)").unwrap();
conn.execute("INSERT INTO t VALUES (?1)", [&[0x01u8, 0x02][..]])
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab));
app.on_key(KeyEvent::from(KeyCode::Enter)); press(&mut app, 'e');
assert_eq!(app.screen, super::Screen::HexEdit);
press(&mut app, 'i');
press(&mut app, 'f');
press(&mut app, 'f');
app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
app.on_key(KeyEvent::from(KeyCode::Esc)); press(&mut app, 'q'); assert_eq!(
app.screen,
super::Screen::Detail,
"closing returns to where it opened from"
);
assert_eq!(app.detail_value, [0xff, 0x02], "with the new bytes");
let _ = std::fs::remove_file(path);
}
#[test]
fn handing_out_the_archive_bytes_does_not_copy_them() {
let path = scratch("bin");
std::fs::write(&path, b"zdbview archive bytes, shared not copied").unwrap();
let store = RkyvStore::open(&path).unwrap();
let original = std::sync::Arc::clone(&store.bytes);
let app = App::with_theme(Store::Rkyv(store), Theme::from_name(ThemeName::NeonSprawl));
let handed = match &app.store {
Store::Rkyv(r) => r.bytes.clone(),
_ => unreachable!(),
};
assert!(
std::sync::Arc::ptr_eq(&original, &handed),
"the same allocation must come back, not a copy of it"
);
assert_eq!(&*handed, b"zdbview archive bytes, shared not copied");
let _ = std::fs::remove_file(path);
}
#[test]
fn search_steps_through_filtered_lists_only() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE keep_a (v TEXT); CREATE TABLE drop_b (v TEXT);
CREATE TABLE keep_c (v TEXT);
INSERT INTO keep_a VALUES ('x');",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.set_filter("keep".to_string());
app.search = "_".to_string();
app.table_idx = 0;
app.search_next(true);
assert_eq!(
app.sqlite().unwrap().tables[app.table_idx],
"keep_c",
"n skipped the filtered-out table"
);
app.search_next(true);
assert_eq!(
app.sqlite().unwrap().tables[app.table_idx],
"keep_a",
"and wraps inside the filtered list"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn n_scrolls_the_grid_to_a_match_on_another_page() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (v TEXT)").unwrap();
{
let tx = conn.unchecked_transaction().unwrap();
let mut stmt = tx.prepare("INSERT INTO t (v) VALUES (?1)").unwrap();
for i in 0..1200 {
stmt.execute(rusqlite::params![if i == 900 {
"needle".to_string()
} else {
format!("hay-{i}")
}])
.unwrap();
}
drop(stmt);
tx.commit().unwrap();
}
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab)); await_queries(&mut app);
assert_eq!(app.page_offset, 0, "starts on the first page");
app.search = "needle".into();
app.search_next(true);
await_queries(&mut app);
assert_eq!(app.page_offset, 500, "the grid moved to the match's page");
assert_eq!(app.row_idx, 400, "and selected the match");
let row = &app.rows.as_ref().unwrap().rows[app.row_idx];
assert_eq!(row[0], "needle", "the selected row is the match");
let _ = std::fs::remove_file(path);
}
#[test]
fn typing_a_filter_leaves_the_grid_showing_the_final_pattern() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE t (v TEXT);
INSERT INTO t VALUES ('alpha'), ('alpine'), ('beta'), ('gamma');",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab));
await_queries(&mut app);
for pattern in ["a", "al", "alp", "alpi"] {
app.set_filter(pattern.to_string());
}
await_queries(&mut app);
let rows = &app.rows.as_ref().unwrap().rows;
assert_eq!(rows.len(), 1, "only `alpine` matches `alpi`: {rows:?}");
assert_eq!(rows[0][0], "alpine");
let _ = std::fs::remove_file(path);
}
#[test]
fn stats_screen_describes_columns_and_counts_values() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE t (tag TEXT, qty INTEGER);
INSERT INTO t VALUES ('a', 1), ('a', 2), ('b', NULL);",
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab));
press(&mut app, 'A');
assert_eq!(app.screen, super::Screen::Stats);
assert!(app.stats_pending.is_some());
let rows = frame_rows(&mut app, 110, 20);
assert!(contains(&rows, "analyzing"), "{:?}", rows[0]);
app.on_key(KeyEvent::from(KeyCode::Enter));
assert!(app.stats_freq.is_none(), "nothing to drill into yet");
await_stats(&mut app);
assert_eq!(app.stats.len(), 2, "one row per column");
let rows = frame_rows(&mut app, 110, 20);
assert!(contains(&rows, "distinct"), "{rows:?}");
assert!(contains(&rows, "columns of t"), "{:?}", rows[0]);
app.on_key(KeyEvent::from(KeyCode::Enter));
let (col, freq) = app.stats_freq.as_ref().expect("a frequency table");
assert_eq!(col, "tag");
assert_eq!(freq[0], ("a".to_string(), 2), "the common value leads");
let rows = frame_rows(&mut app, 110, 20);
assert!(contains(&rows, "most common values"), "{rows:?}");
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.stats_freq.is_none());
assert_eq!(app.screen, super::Screen::Stats, "still on the screen");
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.screen, super::Screen::Main);
press(&mut app, 'A');
await_stats(&mut app);
app.on_key(KeyEvent::from(KeyCode::Enter));
press(&mut app, 'j');
assert!(
app.stats_freq.is_none(),
"the table belonged to the old column"
);
assert_eq!(app.stats_idx, 1);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_blob_cell_edits_in_the_hex_editor() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (raw BLOB, txt TEXT)")
.unwrap();
conn.execute(
"INSERT INTO t VALUES (?1, 'plain')",
[&[0x00u8, 0xffu8][..]],
)
.unwrap();
drop(conn);
let mut app = App::with_theme(
Store::Sqlite(SqliteStore::open(&path).unwrap()),
Theme::from_name(ThemeName::NeonSprawl),
);
app.on_key(KeyEvent::from(KeyCode::Tab));
press(&mut app, 'e');
assert_eq!(
app.screen,
super::Screen::HexEdit,
"blob opens the hex editor"
);
assert_eq!(app.hex.as_ref().unwrap().bytes, [0x00, 0xff]);
press(&mut app, 'i');
press(&mut app, '4');
press(&mut app, '1');
app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
let store = SqliteStore::open(&path).unwrap();
assert_eq!(
store
.cell_bytes_keyed("t", &crate::sqlite::RowKey::Rowid(1), "raw")
.unwrap(),
[0x41, 0xff],
"the edited byte reached the database"
);
assert!(
store
.cell_is_blob_keyed("t", &crate::sqlite::RowKey::Rowid(1), "raw")
.unwrap(),
"still a blob, not text"
);
app.on_key(KeyEvent::from(KeyCode::Esc)); app.on_key(KeyEvent::from(KeyCode::Char('q')));
app.on_key(KeyEvent::from(KeyCode::Right));
press(&mut app, 'e');
assert_eq!(app.screen, super::Screen::Main);
assert!(
matches!(app.mode, super::Mode::EditCell(_)),
"text edits inline"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn dbinfo_screen_reports_and_checks_on_demand() {
let (mut app, path) = sqlite_app_rows(3);
press(&mut app, 'D');
assert_eq!(app.screen, super::Screen::DbInfo);
assert!(
app.dbinfo.iter().any(|(k, _)| k == "page size"),
"pragmas are read when the screen opens: {:?}",
app.dbinfo
);
assert!(
app.dbinfo_checks.is_empty(),
"the slow checks wait for a keypress"
);
let rows = frame_rows(&mut app, 100, 24);
assert!(contains(&rows, "journal mode"), "{rows:?}");
assert!(contains(&rows, "database —"), "{:?}", rows[0]);
press(&mut app, 'i');
assert_eq!(app.dbinfo_checks, vec!["integrity check: ok".to_string()]);
press(&mut app, 'Q');
assert_eq!(app.dbinfo_checks, vec!["quick check: ok".to_string()]);
press(&mut app, 'f');
assert_eq!(
app.dbinfo_checks,
vec!["foreign-key lint: every foreign key has an index".to_string()]
);
let rows = frame_rows(&mut app, 100, 24);
assert!(contains(&rows, "every foreign key"), "{rows:?}");
press(&mut app, 'h');
assert_eq!(app.help_ctx(), HelpCtx::DbInfo);
press(&mut app, 'h');
press(&mut app, 'D');
assert_eq!(app.screen, super::Screen::Main);
press(&mut app, 'D');
press(&mut app, 'z');
assert_eq!(app.dbinfo_checks.len(), 1);
assert!(
app.dbinfo_checks[0].starts_with("ANALYZE: done"),
"got {:?}",
app.dbinfo_checks[0]
);
press(&mut app, 'v');
assert!(
app.dbinfo_checks[0].starts_with("VACUUM: done"),
"got {:?}",
app.dbinfo_checks[0]
);
press(&mut app, 'r');
assert!(
app.dbinfo_checks[0].starts_with("REINDEX: done"),
"got {:?}",
app.dbinfo_checks[0]
);
assert!(
app.dbinfo.iter().any(|(k, _)| k == "page count"),
"the pragmas are re-read after a rewrite"
);
press(&mut app, 'q');
assert!(app.quit, "q quits from the database screen too");
let _ = std::fs::remove_file(path);
}
#[test]
fn overlay_keys_route_through_the_app() {
let mut app = rkyv_app();
press(&mut app, 'h');
assert!(app.ov.help, "h must open help");
press(&mut app, 'j');
assert!(!app.ov.help, "any key closes help");
press(&mut app, 'c');
assert!(app.ov.chooser, "c must open the scheme chooser");
press(&mut app, 'c');
assert!(!app.ov.chooser);
press(&mut app, 'C');
assert!(app.ov.editor, "C must open the palette editor");
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(!app.ov.editor);
press(&mut app, 'e');
assert!(!app.ov.active(), "e must not open an overlay");
}
#[test]
fn columns_move_with_arrows_and_h_opens_help() {
let (mut app, path) = sqlite_app();
app.on_key(KeyEvent::from(KeyCode::Tab)); app.on_key(KeyEvent::from(KeyCode::Right));
assert_eq!(app.col_idx, 1, "Right must move a column");
app.on_key(KeyEvent::from(KeyCode::Left));
assert_eq!(app.col_idx, 0, "Left must move back");
app.on_key(KeyEvent::from(KeyCode::Right));
press(&mut app, 'h');
assert_eq!(app.col_idx, 1, "h must not move the column");
assert!(app.ov.help, "h must open help instead");
let _ = std::fs::remove_file(&path);
}
#[test]
fn s_cycles_the_sort_on_the_cursor_column() {
let (mut app, path) = sqlite_app();
app.on_key(KeyEvent::from(KeyCode::Tab)); app.on_key(KeyEvent::from(KeyCode::Right)); assert!(app.sort.is_none(), "no sort until asked for");
press(&mut app, 's');
let sort = app.sort.as_ref().expect("s must sort");
assert_eq!(sort.column, "b");
assert!(!sort.desc, "first press sorts ascending");
let rows = frame_rows(&mut app, 100, 20);
assert!(contains(&rows, "b ▲"), "ascending marker missing");
assert!(contains(&rows, "sorted by b ascending"), "no sort toast");
press(&mut app, 's');
assert!(app.sort.as_ref().unwrap().desc, "second press flips it");
let rows = frame_rows(&mut app, 100, 20);
assert!(contains(&rows, "b ▼"), "descending marker missing");
press(&mut app, 's');
assert!(app.sort.is_none(), "third press clears the sort");
press(&mut app, '>');
assert_eq!(app.sort.as_ref().unwrap().column, "b");
press(&mut app, '>');
assert_eq!(app.sort.as_ref().unwrap().column, "c");
press(&mut app, '<');
assert_eq!(app.sort.as_ref().unwrap().column, "b");
app.on_key(KeyEvent::from(KeyCode::Tab));
app.select_table(0);
assert!(app.sort.is_none(), "sort must not survive a table switch");
let _ = std::fs::remove_file(&path);
}
fn script_shard_app() -> (App, std::path::PathBuf) {
let path = scratch("rkyv");
std::fs::write(
&path,
crate::formats::test_script_shard_bytes("/tmp/a.sh", b"old"),
)
.unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
(
App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl)),
path,
)
}
#[test]
fn e_opens_the_hex_editor_on_the_current_value() {
let (mut app, path) = script_shard_app();
assert_eq!(app.screen, super::Screen::Main);
press(&mut app, 'e');
assert_eq!(app.screen, super::Screen::HexEdit);
let ed = app.hex.as_ref().expect("editor open");
assert_eq!(ed.bytes, b"old", "pre-filled with the record's value");
assert_eq!(ed.label, "/tmp/a.sh");
let rows = frame_rows(&mut app, 90, 16);
assert!(contains(&rows, "hex editor"), "editor not drawn");
assert!(contains(&rows, "6f 6c 64"), "hex cells for \"old\"");
assert!(contains(&rows, "|old"), "ascii gutter");
let _ = std::fs::remove_file(&path);
}
#[test]
fn hex_editor_saves_edited_bytes_back_into_the_archive() {
let (mut app, path) = script_shard_app();
press(&mut app, 'e');
press(&mut app, 'i');
app.on_key(KeyEvent::from(KeyCode::Tab));
for c in "new".chars() {
press(&mut app, c);
}
assert_eq!(app.hex.as_ref().unwrap().bytes, b"new");
assert!(app.hex.as_ref().unwrap().dirty);
app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(!app.hex.as_ref().unwrap().dirty, "save clears dirty");
assert_eq!(
app.decoded.as_ref().unwrap().records[0].value,
b"new".to_vec(),
"the reloaded record must carry the edited bytes"
);
let on_disk = std::fs::read(&path).unwrap();
assert!(
crate::formats::try_decode(&on_disk).unwrap().records[0].value == b"new".to_vec(),
"write-back must reach the file"
);
app.on_key(KeyEvent::from(KeyCode::Esc));
press(&mut app, 'q');
assert_eq!(app.screen, super::Screen::Main);
assert!(app.hex.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn hex_editor_keeps_h_and_c_for_itself() {
let (mut app, path) = script_shard_app();
press(&mut app, 'e');
press(&mut app, 'l');
press(&mut app, 'h'); assert!(!app.ov.help, "h must not open help inside the editor");
assert_eq!(app.screen, super::Screen::HexEdit);
press(&mut app, 'i');
press(&mut app, 'c'); assert!(
!app.ov.chooser,
"c must not open the chooser inside the editor"
);
assert_eq!(app.hex.as_ref().unwrap().bytes[0], 0xcf);
let _ = std::fs::remove_file(&path);
}
fn scan_hit(
path: &str,
kind: Kind,
format: Option<&'static str>,
secs: u64,
rank: u8,
) -> crate::scan::Hit {
crate::scan::Hit {
path: path.into(),
kind,
format,
size: 1024,
modified: std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
rank,
}
}
#[test]
fn scan_hits_are_ranked_before_being_shown() {
let mut choices: Vec<super::Choice> = Vec::new();
super::merge_hits(
&mut choices,
vec![
scan_hit("/h/.zshrs/compsys.db", Kind::Sqlite, None, 300, 2),
scan_hit("/h/.zshrs/images/a.rkyv", Kind::Rkyv, None, 100, 1),
scan_hit(
"/h/.zshrs/scripts.rkyv",
Kind::Rkyv,
Some("zshrs script cache (ZRSC)"),
50,
0,
),
scan_hit("/h/.zshrs/index.rkyv", Kind::Rkyv, None, 200, 1),
],
);
let order: Vec<&str> = choices.iter().map(|c| c.name()).collect();
assert_eq!(
order,
vec!["scripts.rkyv", "index.rkyv", "a.rkyv", "compsys.db"],
"recognized shard first, then rkyv newest-first, then databases"
);
}
#[test]
fn scan_hits_do_not_duplicate_recent_files() {
let path = scratch("db");
std::fs::write(&path, b"x").unwrap();
let entry = Entry {
path: path.clone(),
kind: Kind::Sqlite,
opened: std::time::SystemTime::now(),
};
let mut choices: Vec<super::Choice> = vec![super::Choice::from_entry(&entry)];
super::merge_hits(
&mut choices,
vec![
crate::scan::Hit {
path: path.clone(),
kind: Kind::Sqlite,
format: None,
size: 1,
modified: std::time::SystemTime::now(),
rank: 2,
},
scan_hit("/h/other.db", Kind::Sqlite, None, 10, 2),
],
);
assert_eq!(choices.len(), 2, "the duplicate must be dropped");
assert_eq!(choices[0].path, path);
assert!(choices[0].opened.is_some(), "still a recent file");
assert!(choices[1].path.ends_with("other.db"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn picker_shows_scan_progress_and_row_details() {
let theme = crate::theme::Theme::from_name(ThemeName::NeonSprawl);
let mut choices: Vec<super::Choice> = Vec::new();
super::merge_hits(
&mut choices,
vec![scan_hit(
"/h/.zshrs/scripts.rkyv",
Kind::Rkyv,
Some("zshrs script cache (ZRSC)"),
50,
0,
)],
);
let render = |choices: &[super::Choice], scanning: Option<usize>| -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(100, 8)).unwrap();
term.draw(|f| {
let view: Vec<usize> = (0..choices.len()).collect();
super::render_picker(f, choices, &view, 0, "", None, scanning, None, &theme);
})
.unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
};
let rows = render(&choices, Some(1));
assert!(
contains(&rows, "scanning… 1 found"),
"no progress in the title"
);
assert!(contains(&rows, "1 files (0 recent)") || contains(&rows, "1 files"));
assert!(contains(&rows, "scripts.rkyv"));
assert!(contains(&rows, "1.0 K"), "size column for a scanned row");
assert!(
contains(&rows, "zshrs script cache (ZRSC)"),
"selected row's format on the bottom line"
);
let rows = render(&choices, None);
assert!(!contains(&rows, "scanning"));
let rows = render(&[], None);
assert!(contains(&rows, "Nothing found."));
assert!(contains(&rows, "--scan"));
let rows = render(&[], Some(0));
assert!(contains(&rows, "Scanning for databases"));
}
#[test]
fn slash_filters_the_records_list_while_typing() {
let path = scratch("rkyv");
let recs: Vec<(String, Vec<u8>)> = ["alpha", "bravo", "charlie", "delta"]
.iter()
.map(|n| (format!("/tmp/{n}.sh"), vec![b'x']))
.collect();
std::fs::write(&path, crate::formats::test_script_shard_bytes_many(&recs)).unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let mut app = App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl));
assert_eq!(app.visible_records().len(), 4);
press(&mut app, '/');
press(&mut app, 'a');
press(&mut app, 'l');
let listed: Vec<String> = app
.visible_records()
.iter()
.map(|&i| app.decoded.as_ref().unwrap().records[i].key.clone())
.collect();
assert_eq!(listed.len(), 1, "got {listed:?}");
assert!(listed[0].contains("alpha"));
assert!(app.status.contains("1 match"), "got {:?}", app.status);
assert!(app.visible_records().contains(&app.record_idx));
let rows = frame_rows(&mut app, 100, 16);
assert!(contains(&rows, "alpha"));
assert!(!contains(&rows, "bravo"), "filtered-out key still drawn");
assert!(contains(&rows, "1/4 keys"), "count missing from the title");
app.on_key(KeyEvent::from(KeyCode::Backspace));
assert_eq!(app.visible_records().len(), 4, "'a' matches every key");
for c in "zzz".chars() {
press(&mut app, c);
}
assert!(app.visible_records().is_empty());
assert!(app.status.contains("0 matches"), "got {:?}", app.status);
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.filter.is_empty(), "Esc must clear the filter");
assert_eq!(app.visible_records().len(), 4);
assert_eq!(app.record_idx, 0);
press(&mut app, '/');
for c in "brav".chars() {
press(&mut app, c);
}
app.on_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(app.filter, "brav", "Enter keeps the filter");
assert_eq!(app.visible_records().len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn navigation_skips_filtered_out_rows() {
let path = scratch("rkyv");
let recs: Vec<(String, Vec<u8>)> = ["a_one", "b_skip", "a_two", "c_skip", "a_three"]
.iter()
.map(|n| (format!("/tmp/{n}.sh"), vec![b'x']))
.collect();
std::fs::write(&path, crate::formats::test_script_shard_bytes_many(&recs)).unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let mut app = App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl));
press(&mut app, '/');
for c in "a_".chars() {
press(&mut app, c);
}
app.on_key(KeyEvent::from(KeyCode::Enter));
let visible = app.visible_records();
assert_eq!(visible.len(), 3, "three keys start with a_");
app.record_idx = visible[0];
for expected in &visible[1..] {
press(&mut app, 'j');
assert_eq!(
app.record_idx, *expected,
"j must land on the next listed row"
);
}
press(&mut app, 'j');
assert_eq!(app.record_idx, *visible.last().unwrap());
press(&mut app, 'k');
assert_eq!(app.record_idx, visible[1]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn slash_filters_the_strings_view() {
let mut app = rkyv_app_with(b"\x00\x00alpha\x00\x00bravo\x00\x00charlie\x00\x00");
app.on_key(KeyEvent::from(KeyCode::Char('2')));
let all = app.visible_strings().len();
assert!(all >= 3, "{:?}", app.strings);
press(&mut app, '/');
for c in "brav".chars() {
press(&mut app, c);
}
let listed = app.visible_strings();
assert_eq!(listed.len(), 1);
assert!(app.strings[listed[0]].text.contains("bravo"));
let rows = frame_rows(&mut app, 100, 16);
assert!(contains(&rows, "bravo") && !contains(&rows, "alpha"));
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.visible_strings().len(), all);
}
#[test]
fn slash_filters_sqlite_rows_across_the_whole_table() {
let (mut app, path) = sqlite_app_rows(60);
app.on_key(KeyEvent::from(KeyCode::Tab)); assert_eq!(app.rows.as_ref().unwrap().total, 60);
press(&mut app, '/');
press(&mut app, '4');
let view = app.rows.as_ref().unwrap();
assert_eq!(view.total, 15, "total must count matches, not all rows");
assert!(
view.rows.iter().all(|r| r.iter().any(|c| c.contains('4'))),
"every listed row must match: {:?}",
view.rows
);
assert!(app.status.contains("15 matches"), "got {:?}", app.status);
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.rows.as_ref().unwrap().total, 60);
assert!(app.filter.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn slash_filters_the_table_list() {
let path = scratch("db");
let conn = rusqlite::Connection::open(&path).unwrap();
for t in ["users", "user_roles", "orders"] {
conn.execute(&format!("CREATE TABLE {t} (x)"), []).unwrap();
}
drop(conn);
let store = Store::Sqlite(SqliteStore::open(&path).unwrap());
let mut app = App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl));
assert_eq!(app.visible_tables().len(), 3);
press(&mut app, '/');
for c in "user".chars() {
press(&mut app, c);
}
assert_eq!(app.visible_tables().len(), 2);
let rows = frame_rows(&mut app, 100, 16);
assert!(contains(&rows, "users"));
assert!(
!contains(&rows, "orders"),
"a filtered-out table is still on screen: {rows:#?}"
);
assert!(
contains(&rows, "tables 2/3"),
"count missing: {:?}",
rows[0]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn paging_moves_by_a_screenful() {
let (mut app, path) = sqlite_app_rows(120);
app.on_key(KeyEvent::from(KeyCode::Tab)); frame_rows(&mut app, 80, 24);
assert_eq!(app.page_rows, 20);
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.row_idx, 20, "PageDown moves one screenful");
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.row_idx, 40);
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.row_idx, 20);
app.on_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert_eq!(app.row_idx, 40);
app.on_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
assert_eq!(app.row_idx, 20);
for _ in 0..20 {
app.on_key(KeyEvent::from(KeyCode::PageDown));
}
assert_eq!(app.row_idx, 119, "clamps to the last loaded row");
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.row_idx, 99);
for _ in 0..20 {
app.on_key(KeyEvent::from(KeyCode::PageUp));
}
assert_eq!(app.row_idx, 0);
let _ = std::fs::remove_file(&path);
let (mut app, path) = sqlite_app_rows(120);
app.on_key(KeyEvent::from(KeyCode::Tab));
frame_rows(&mut app, 80, 12);
assert_eq!(app.page_rows, 8);
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.row_idx, 8);
let _ = std::fs::remove_file(&path);
}
#[test]
fn paging_works_in_every_rkyv_view() {
let mut app = rkyv_app_with(&vec![b'A'; 4096]);
frame_rows(&mut app, 80, 24);
let step = app.page_rows;
assert!(step > 1);
app.on_key(KeyEvent::from(KeyCode::Char('3')));
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.hex_row, step);
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.hex_row, 0);
app.on_key(KeyEvent::from(KeyCode::Char('2')));
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.string_idx, app.strings.len().saturating_sub(1));
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.string_idx, 0);
app.on_key(KeyEvent::from(KeyCode::Char('1')));
app.on_key(KeyEvent::from(KeyCode::PageDown));
}
#[test]
fn paging_walks_the_records_view() {
let path = scratch("rkyv");
let mut keys: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..40 {
keys.push((format!("/tmp/s{i:02}.sh"), vec![b'x']));
}
std::fs::write(&path, crate::formats::test_script_shard_bytes_many(&keys)).unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let mut app = App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl));
frame_rows(&mut app, 80, 14);
let step = app.page_rows;
assert_eq!(app.record_idx, 0);
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.record_idx, step);
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.record_idx, 0);
for _ in 0..20 {
app.on_key(KeyEvent::from(KeyCode::PageDown));
}
assert_eq!(app.record_idx, 39, "clamps to the last record");
let _ = std::fs::remove_file(&path);
}
#[test]
fn paging_scrolls_the_detail_and_schema_screens() {
let (mut app, path) = sqlite_app_rows(5);
app.on_key(KeyEvent::from(KeyCode::Tab));
app.on_key(KeyEvent::from(KeyCode::Enter)); assert_eq!(app.screen, super::Screen::Detail);
app.detail_value = vec![0u8; 16 * 400];
frame_rows(&mut app, 80, 30);
let step = app.page_rows;
assert!(step > 1);
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.detail_scroll, step);
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.detail_scroll, 0);
app.on_key(KeyEvent::from(KeyCode::Esc));
app.on_key(KeyEvent::from(KeyCode::Char('S'))); assert_eq!(app.screen, super::Screen::Schema);
frame_rows(&mut app, 80, 30);
let step = app.page_rows;
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert_eq!(app.schema_scroll, step);
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert_eq!(app.schema_scroll, 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn esc_on_the_first_level_returns_to_the_file_list() {
let mut app = rkyv_app();
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.reopen, "Esc must ask for the file list");
assert!(app.quit, "and end the app loop");
let mut app = rkyv_app();
press(&mut app, 'q');
assert!(app.quit);
assert!(!app.reopen, "q must not reopen the picker");
let (mut app, path) = sqlite_app();
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.reopen && app.quit);
let _ = std::fs::remove_file(&path);
let (mut app, path) = script_shard_app();
app.on_key(KeyEvent::from(KeyCode::Enter)); assert_eq!(app.screen, super::Screen::Detail);
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.screen, super::Screen::Main);
assert!(
!app.reopen,
"Esc in a nested screen must not leave the file"
);
assert!(!app.quit);
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.reopen && app.quit);
let _ = std::fs::remove_file(&path);
}
#[test]
fn o_returns_to_the_file_picker() {
let mut app = rkyv_app();
assert!(!app.reopen);
press(&mut app, 'o');
assert!(app.reopen, "o must ask for the picker");
assert!(app.quit, "and end the app loop");
let (mut app, path) = script_shard_app();
press(&mut app, 'e');
let before = app.hex.as_ref().unwrap().bytes.len();
press(&mut app, 'o');
assert!(!app.reopen, "the editor keeps o for inserting a byte");
assert_eq!(app.hex.as_ref().unwrap().bytes.len(), before + 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn sql_editor_runs_statements_and_shows_results() {
use crate::sqledit::Entry;
let (mut app, path) = sqlite_app_rows(5);
press(&mut app, ':');
assert_eq!(app.screen, super::Screen::Sql);
assert!(app.sql.is_some(), "the editor is open");
app.execute_sql("SELECT a FROM t ORDER BY a LIMIT 2");
let log = app.sql.as_ref().unwrap().transcript();
assert!(
matches!(log.last(), Some(Entry::Timing(_))),
"the run is timed: {:?}",
log.last()
);
match &log[log.len() - 2] {
Entry::Rows {
columns,
rows,
truncated,
} => {
assert_eq!(columns, &["a".to_string()]);
assert_eq!(rows.len(), 2, "two rows for LIMIT 2");
assert!(!truncated);
}
other => panic!("expected rows, got {other:?}"),
}
app.execute_sql("UPDATE t SET b = 'z'");
let log = app.sql.as_ref().unwrap().transcript();
assert_eq!(log[log.len() - 2], Entry::Changed(5));
assert!(
app.rows.as_ref().unwrap().rows.iter().all(|r| r[1] == "z"),
"the grid must show the write"
);
app.execute_sql("SELECT * FROM nope");
let log = app.sql.as_ref().unwrap().transcript();
match &log[log.len() - 2] {
Entry::Error(msg) => assert!(msg.contains("nope"), "got {msg:?}"),
other => panic!("expected an error, got {other:?}"),
}
app.on_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let mut e = KeyEvent::from(KeyCode::Char('e'));
e.modifiers = KeyModifiers::ALT;
app.on_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL));
for c in "SELECT * FROM t".chars() {
app.on_key(KeyEvent::from(KeyCode::Char(c)));
}
app.on_key(e);
let log = app.sql.as_ref().unwrap().transcript();
let plan = log
.iter()
.rev()
.find_map(|x| match x {
Entry::Plan(steps) => Some(steps.clone()),
_ => None,
})
.expect("a plan entry");
assert!(
plan.iter()
.any(|s| s.contains("SCAN") || s.contains("TABLE")),
"plan should name a scan: {plan:?}"
);
let rows = frame_rows(&mut app, 100, 20);
assert!(contains(&rows, "SQL —"), "editor not drawn: {:?}", rows[0]);
assert!(contains(&rows, "no such table"), "error not on screen");
app.on_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.screen, super::Screen::Main);
press(&mut app, ':');
assert!(
!app.sql.as_ref().unwrap().transcript().is_empty(),
"reopening keeps the transcript"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn write_monitor_shows_live_writes() {
use std::io::Write;
let mut app = rkyv_app();
press(&mut app, 'w');
assert_eq!(app.screen, super::Screen::Top);
let watcher = app.top.as_ref().expect("watcher running");
assert!(
!watcher.is_empty(),
"the open file must be watched at least"
);
assert!(app.status.contains("watching"), "got {:?}", app.status);
assert_eq!(app.help_ctx(), HelpCtx::Top);
let path = scratch("rkyv");
std::fs::write(&path, b"start").unwrap();
app.top = Some(crate::monitor::Monitor::new([(path.clone(), Kind::Rkyv)]));
let rows = frame_rows(&mut app, 110, 14);
assert!(
contains(&rows, "writes —"),
"no monitor header: {:?}",
rows[0]
);
assert!(contains(&rows, "activity"), "no column header");
assert!(contains(&rows, "0 active"), "nothing should be active yet");
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(&[b'x'; 4096]).unwrap();
}
let m = app.top.as_mut().unwrap();
m.watcher.interval = std::time::Duration::ZERO;
assert!(m.tick());
m.watcher.interval = crate::watch::DEFAULT_INTERVAL;
let rows = frame_rows(&mut app, 110, 14);
assert!(contains(&rows, "1 active"), "the write was not noticed");
assert!(contains(&rows, "4.0 K"), "written bytes missing: {rows:#?}");
assert!(
rows.iter().any(|r| r.contains('█') || r.contains('▁')),
"no activity sparkline drawn"
);
let before = app.top.as_ref().unwrap().sort;
press(&mut app, 's');
assert_ne!(app.top.as_ref().unwrap().sort, before);
assert!(app.status.contains("sorted by"));
press(&mut app, 'p');
assert!(app.top.as_ref().unwrap().watcher.paused);
assert!(contains(&frame_rows(&mut app, 110, 14), "PAUSED"));
press(&mut app, 'p');
assert!(!app.top.as_ref().unwrap().watcher.paused);
let iv = app.top.as_ref().unwrap().watcher.interval;
press(&mut app, '+');
assert!(
app.top.as_ref().unwrap().watcher.interval < iv,
"+ samples faster"
);
press(&mut app, '-');
assert_eq!(app.top.as_ref().unwrap().watcher.interval, iv);
app.on_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(app.open_next(), Some(path.clone()));
assert!(app.reopen && app.quit);
let mut app = rkyv_app();
press(&mut app, 'w');
press(&mut app, 'w');
assert_eq!(app.screen, super::Screen::Main);
assert!(app.top.is_none(), "the watcher is dropped on the way out");
let _ = std::fs::remove_file(&path);
}
#[test]
fn opening_a_file_keeps_the_picker_scheme() {
let path = scratch("rkyv");
std::fs::write(
&path,
crate::formats::test_script_shard_bytes("/tmp/a.sh", b"x"),
)
.unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let handed = Theme::from_name(ThemeName::BladeRunner);
let app = App::with_theme(store, handed);
assert_eq!(app.theme().name, ThemeName::BladeRunner);
assert_eq!(app.theme().accent, handed.accent);
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let custom = Theme::from_palette(ThemeName::BladeRunner, [10, 20, 30, 40, 50, 60]);
let app = App::with_theme(store, custom);
assert_eq!(app.theme().accent, ratatui::style::Color::Indexed(20));
assert_eq!(app.theme().primary, ratatui::style::Color::Indexed(10));
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let mut app = App::with_theme(store, handed);
press(&mut app, 'c');
app.on_key(KeyEvent::from(KeyCode::Down));
let previewed = app.theme().name;
assert_ne!(previewed, ThemeName::BladeRunner, "chooser previews live");
assert_eq!(
app.theme().name,
previewed,
"theme() reports the live scheme"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn arrows_navigate_while_the_filter_prompt_is_open() {
let path = scratch("rkyv");
let recs: Vec<(String, Vec<u8>)> = ["a_one", "b_skip", "a_two", "c_skip", "a_three"]
.iter()
.map(|n| (format!("/tmp/{n}.sh"), vec![b'x']))
.collect();
std::fs::write(&path, crate::formats::test_script_shard_bytes_many(&recs)).unwrap();
let store = Store::Rkyv(RkyvStore::open(&path).unwrap());
let mut app = App::with_theme(store, Theme::from_name(ThemeName::NeonSprawl));
press(&mut app, '/');
for c in "a_".chars() {
press(&mut app, c);
}
let visible = app.visible_records();
assert_eq!(visible.len(), 3);
assert!(
matches!(app.mode, super::Mode::Search(_)),
"prompt must stay open"
);
app.on_key(KeyEvent::from(KeyCode::Down));
assert!(
matches!(app.mode, super::Mode::Search(_)),
"Down must not close it"
);
assert_eq!(app.record_idx, visible[1], "Down moves within the matches");
app.on_key(KeyEvent::from(KeyCode::Down));
assert_eq!(app.record_idx, visible[2]);
app.on_key(KeyEvent::from(KeyCode::Down));
assert_eq!(app.record_idx, visible[2], "clamps at the last match");
app.on_key(KeyEvent::from(KeyCode::Up));
assert_eq!(app.record_idx, visible[1]);
press(&mut app, 't');
assert_eq!(app.visible_records().len(), 2, "a_two and a_three");
let _ = std::fs::remove_file(&path);
let (mut app, path) = sqlite_app_rows(60);
app.on_key(KeyEvent::from(KeyCode::Tab));
press(&mut app, '/');
press(&mut app, '4');
assert_eq!(app.row_idx, 0);
app.on_key(KeyEvent::from(KeyCode::Down));
assert_eq!(app.row_idx, 1, "Down must move the filtered grid");
app.on_key(KeyEvent::from(KeyCode::Up));
assert_eq!(app.row_idx, 0);
frame_rows(&mut app, 80, 24);
app.on_key(KeyEvent::from(KeyCode::PageDown));
assert!(app.row_idx > 0);
assert!(matches!(app.mode, super::Mode::Search(_)), "still typing");
let _ = std::fs::remove_file(&path);
}
#[test]
fn picker_prompt_keys_navigate_and_edit() {
use super::{filter_prompt_key, Prompt};
let mut filter = String::new();
let mut sel = 0usize;
let (last, page) = (9usize, 4usize);
assert_eq!(
filter_prompt_key(KeyCode::Char('z'), &mut filter, &mut sel, last, page),
Prompt::Open
);
assert_eq!(filter, "z");
assert_eq!(sel, 0);
filter_prompt_key(KeyCode::Down, &mut filter, &mut sel, last, page);
filter_prompt_key(KeyCode::Down, &mut filter, &mut sel, last, page);
assert_eq!((sel, filter.as_str()), (2, "z"));
filter_prompt_key(KeyCode::Up, &mut filter, &mut sel, last, page);
assert_eq!(sel, 1);
filter_prompt_key(KeyCode::PageDown, &mut filter, &mut sel, last, page);
assert_eq!(sel, 5);
filter_prompt_key(KeyCode::PageUp, &mut filter, &mut sel, last, page);
assert_eq!(sel, 1);
filter_prompt_key(KeyCode::End, &mut filter, &mut sel, last, page);
assert_eq!(sel, last, "End goes to the last match");
filter_prompt_key(KeyCode::Home, &mut filter, &mut sel, last, page);
assert_eq!(sel, 0);
filter_prompt_key(KeyCode::Up, &mut filter, &mut sel, last, page);
assert_eq!(sel, 0);
sel = last;
filter_prompt_key(KeyCode::Down, &mut filter, &mut sel, last, page);
assert_eq!(sel, last);
filter_prompt_key(KeyCode::Backspace, &mut filter, &mut sel, last, page);
assert!(filter.is_empty());
assert_eq!(sel, 0);
assert_eq!(
filter_prompt_key(KeyCode::Enter, &mut filter, &mut sel, last, page),
Prompt::Accept
);
assert_eq!(
filter_prompt_key(KeyCode::Esc, &mut filter, &mut sel, last, page),
Prompt::Cancel
);
}
#[test]
fn picker_filter_removes_rows_from_the_list() {
let theme = crate::theme::Theme::from_name(ThemeName::NeonSprawl);
let mut choices: Vec<super::Choice> = Vec::new();
super::merge_hits(
&mut choices,
vec![
scan_hit("/h/.zshrs/scripts.rkyv", Kind::Rkyv, None, 30, 1),
scan_hit("/h/.zshrs/compsys.db", Kind::Sqlite, None, 20, 2),
scan_hit("/h/.pythonrs/scripts.rkyv", Kind::Rkyv, None, 10, 1),
],
);
let render = |filter: &str| -> Vec<String> {
let view: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| super::filter_passes(filter, c.path.to_str().unwrap()))
.map(|(i, _)| i)
.collect();
let mut term = Terminal::new(TestBackend::new(100, 10)).unwrap();
term.draw(|f| {
super::render_picker(f, &choices, &view, 0, filter, None, None, None, &theme);
})
.unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
};
let rows = render("");
assert!(contains(&rows, "compsys.db"));
assert!(contains(&rows, "3 files"));
let rows = render("zshrs");
assert!(contains(&rows, "scripts.rkyv") && contains(&rows, "compsys.db"));
assert!(
!contains(&rows, ".pythonrs"),
"a filtered-out row is still listed: {rows:#?}"
);
assert!(contains(&rows, "2/3 files"), "count missing: {:?}", rows[0]);
assert!(contains(&rows, "/zshrs"), "the pattern must be shown");
let rows = render("compsys");
assert!(contains(&rows, "compsys.db") && !contains(&rows, "pythonrs"));
assert!(contains(&rows, "1/3 files"));
let rows = render("nothing-matches-this");
assert!(contains(&rows, "Nothing matches"));
assert!(contains(&rows, "Esc clears the filter"));
}
#[test]
fn picker_titles_a_reused_scan_with_its_age() {
let theme = crate::theme::Theme::from_name(ThemeName::NeonSprawl);
let mut choices: Vec<super::Choice> = Vec::new();
super::merge_hits(
&mut choices,
vec![scan_hit("/h/.zshrs/scripts.rkyv", Kind::Rkyv, None, 50, 1)],
);
let render = |age: Option<std::time::Duration>| -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(100, 6)).unwrap();
term.draw(|f| {
let view: Vec<usize> = (0..choices.len()).collect();
super::render_picker(f, &choices, &view, 0, "", None, None, age, &theme);
})
.unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area().height)
.map(|y| {
(0..buf.area().width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
};
let rows = render(Some(std::time::Duration::from_secs(3 * 3600)));
assert!(contains(&rows, "scan 3h old"), "age missing: {:?}", rows[0]);
assert!(contains(&rows, "r rescans"), "no way to refresh advertised");
let rows = render(Some(std::time::Duration::ZERO));
assert!(contains(&rows, "just now"));
let rows = render(None);
assert!(!contains(&rows, "rescans"));
}
#[test]
fn age_labels_read_naturally() {
use std::time::Duration;
assert_eq!(super::age_label(Duration::from_secs(5)), "just now");
assert_eq!(super::age_label(Duration::from_secs(600)), "10m old");
assert_eq!(super::age_label(Duration::from_secs(7200)), "2h old");
assert_eq!(super::age_label(Duration::from_secs(3 * 86_400)), "3d old");
}
#[test]
fn a_large_archive_opens_without_blocking() {
let big = vec![0x41u8; super::DECODE_INLINE_MAX + 1024];
let t0 = std::time::Instant::now();
let mut app = rkyv_app_with(&big);
let open = t0.elapsed();
assert!(
open < std::time::Duration::from_secs(2),
"opening took {open:?}"
);
assert!(
app.decoding.is_some(),
"decode must be running in the background"
);
assert!(app.decoded.is_none(), "and not have blocked for the result");
assert_eq!(app.rkyv_view, super::RkyvView::Info);
assert!(app.status.contains("decoding"), "got {:?}", app.status);
app.on_key(KeyEvent::from(KeyCode::Char('0')));
assert_eq!(app.rkyv_view, super::RkyvView::Info);
assert!(
app.status.contains("still decoding"),
"got {:?}",
app.status
);
let rx = app.decoding.as_ref().unwrap();
let _ = rx.recv_timeout(std::time::Duration::from_secs(30));
app.poll_decode();
assert!(
app.decoding.is_none(),
"the receiver is dropped once it lands"
);
assert!(app.decoded.is_none(), "0x41 filler is not a shard");
assert!(app.status.contains("unrecognized"), "got {:?}", app.status);
let (app, path) = script_shard_app();
assert!(app.decoding.is_none());
assert!(app.decoded.is_some(), "small shards decode on open");
let _ = std::fs::remove_file(&path);
}
#[test]
fn string_extraction_is_bounded() {
let mut bytes = Vec::new();
while bytes.len() < 400_000 {
bytes.extend_from_slice(b"abcdefg\x00");
}
let path = scratch("bin");
std::fs::write(&path, &bytes).unwrap();
let store = RkyvStore::open(&path).unwrap();
let t0 = std::time::Instant::now();
let s = store.strings(4);
let took = t0.elapsed();
assert!(took < std::time::Duration::from_secs(1), "took {took:?}");
assert!(s.hits.len() <= 20_000, "cap ignored: {}", s.hits.len());
assert!(s.truncated, "truncation must be reported");
let _ = std::fs::remove_file(&path);
let path = scratch("bin");
std::fs::write(&path, b"hello\x00world\x00").unwrap();
let store = RkyvStore::open(&path).unwrap();
let s = store.strings(4);
assert_eq!(s.hits.len(), 2);
assert!(!s.truncated);
assert_eq!(s.scanned, 12);
let _ = std::fs::remove_file(&path);
}
#[test]
fn help_ctx_follows_the_screen() {
assert_eq!(rkyv_app().help_ctx(), HelpCtx::Rkyv);
let (app, path) = sqlite_app();
assert_eq!(app.help_ctx(), HelpCtx::Sqlite);
let _ = std::fs::remove_file(&path);
let (mut app, path) = script_shard_app();
press(&mut app, 'e');
assert_eq!(app.help_ctx(), HelpCtx::HexEdit);
let _ = std::fs::remove_file(&path);
}
#[test]
fn action_results_raise_a_toast() {
let mut app = rkyv_app();
assert!(app.ov.toast.is_none(), "no toast before any action");
press(&mut app, 'x');
let toast = app.ov.toast.as_ref().expect("an action must toast");
assert!(!toast.text.is_empty());
assert_eq!(app.status, toast.text, "status bar keeps the same text");
}
#[test]
fn overlays_render_over_the_app_screens() {
let mut app = rkyv_app();
app.ov.help = true;
let rows = frame_rows(&mut app, 100, 40);
assert!(contains(&rows, "KEYBOARD SHORTCUTS"));
assert!(contains(&rows, "RKYV"), "store section missing");
app.ov.help = false;
app.screen = super::Screen::Detail;
app.ov.toast("copied 12 bytes to clipboard");
let rows = frame_rows(&mut app, 100, 40);
assert!(
contains(&rows, "copied 12 bytes"),
"toast missing on detail"
);
}
#[test]
fn cursor_left_right_utf8() {
let s = "aé";
assert_eq!(input_right(s, 0), 1); assert_eq!(input_right(s, 1), 3); assert_eq!(input_right(s, 3), 3); assert_eq!(input_left(s, 3), 1); assert_eq!(input_left(s, 1), 0);
assert_eq!(input_left(s, 0), 0);
}
#[test]
fn delete_word_skips_trailing_space() {
let mut s = String::from("foo bar ");
let len = s.len();
let cur = input_delete_word(&mut s, len);
assert_eq!(s, "foo ");
assert_eq!(cur, 4);
}
#[test]
fn hit_testing() {
let r = Rect::new(2, 3, 10, 5); assert!(hit(r, 2, 3));
assert!(hit(r, 11, 7));
assert!(!hit(r, 12, 3)); assert!(!hit(r, 2, 8)); assert!(!hit(r, 1, 3));
}
#[test]
fn find_next_forward_wraps() {
let pred = |i: usize| i == 1 || i == 3;
assert_eq!(find_next(5, 0, true, pred), Some(1));
assert_eq!(find_next(5, 1, true, pred), Some(3));
assert_eq!(find_next(5, 3, true, pred), Some(1)); assert_eq!(find_next(5, 4, true, pred), Some(1));
}
#[test]
fn find_next_backward_wraps() {
let pred = |i: usize| i == 1 || i == 3;
assert_eq!(find_next(5, 4, false, pred), Some(3));
assert_eq!(find_next(5, 3, false, pred), Some(1));
assert_eq!(find_next(5, 1, false, pred), Some(3)); assert_eq!(find_next(5, 0, false, pred), Some(3));
}
#[test]
fn find_next_none_and_empty() {
assert_eq!(find_next(5, 0, true, |_| false), None);
assert_eq!(find_next(0, 0, true, |_| true), None);
}
#[test]
fn find_bytes_forward_and_backward() {
let hay = b"abXYabZZab"; assert_eq!(find_bytes(hay, b"ab", 0, true), Some(4));
assert_eq!(find_bytes(hay, b"ab", 4, true), Some(8));
assert_eq!(find_bytes(hay, b"ab", 8, true), None); assert_eq!(find_bytes(hay, b"ab", 8, false), Some(4));
assert_eq!(find_bytes(hay, b"ab", 4, false), Some(0));
assert_eq!(find_bytes(hay, b"zz", 0, true), None); assert_eq!(find_bytes(hay, b"", 0, true), None);
}
}