pub mod layout;
pub mod render;
pub mod startup;
pub mod state;
use std::collections::BTreeMap;
use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::cursor::Show;
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::{Layout, Rect, Size};
use crate::config::{Config, ShortcutAction};
use crate::db::DataSource;
use crate::db::model::{QueryOutcome, SchemaColumn, TablePage, Value};
use crate::history::QueryHistoryStore;
use crate::operation::{OperationCancellation, OperationContext, OperationFailure, OperationId};
use state::{
AppEvent, AppState, CellEditIdentity, CopyFormat, EditTarget, EditorTab, FilterColumnType,
FilterField, FilterOperator, Overlay, Panel, SqlMode, Visual, dispatch,
};
const MAX_QUERY_ROWS: usize = 1_000;
pub async fn run(source: DataSource) -> io::Result<()> {
let source = Arc::new(source);
let mut app = AppState::new_with_access(Vec::new(), source.is_read_only());
app.backend_name = source.backend_name();
app.supports_cell_edit = source.supports_cell_edit();
apply_config(&mut app, Config::load());
let history_warning = match QueryHistoryStore::from_xdg() {
Ok(store) => match store.load() {
Ok(loaded) => {
let warning = loaded
.issue
.as_ref()
.map(|issue| format!("history: {issue}"));
app.set_query_history_store(store, loaded.history);
warning
}
Err(error) => Some(format!("history unavailable: {error}")),
},
Err(error) => Some(format!("history unavailable: {error}")),
};
if let Some(warning) = history_warning {
app.status = if app.status.is_empty() {
warning
} else {
format!("{}; {warning}", app.status)
};
}
let mut stdout = io::stdout();
let mut session = TerminalSession::enter(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut operations = Operations::new(source);
operations.start_startup(&mut app);
let result = event_loop(&mut terminal, &mut app, &mut operations).await;
drop(terminal);
let warning = operations.take_cleanup_warning();
let cleanup = session.restore();
if let Some(warning) = warning {
eprintln!("{warning}");
}
combine_run_results(result, cleanup)
}
fn combine_run_results(result: io::Result<()>, cleanup: io::Result<()>) -> io::Result<()> {
result.and(cleanup)
}
struct StartupData {
tables: Vec<String>,
catalog: Option<BTreeMap<String, Vec<SchemaColumn>>>,
preview: Option<(String, TablePage)>,
}
enum OperationPayload {
Startup(StartupData),
Rows {
table: String,
page: TablePage,
schema: Option<Vec<SchemaColumn>>,
clear_filters: bool,
},
Filter {
table: String,
sql: String,
page: Option<(TablePage, bool)>,
fallback: Option<QueryOutcome>,
},
Schema {
table: String,
columns: Vec<SchemaColumn>,
},
Sql {
table: Option<String>,
statement: String,
outcome: QueryOutcome,
},
Refresh {
tables: Vec<String>,
loaded: Option<(String, TablePage)>,
catalog: Option<BTreeMap<String, Vec<SchemaColumn>>>,
},
Update {
table: String,
rowid: i64,
value: Value,
selected_row: usize,
selected_col: usize,
active_filter: Option<state::ActiveFilter>,
offset: usize,
},
UpdateRefresh {
table: String,
page: TablePage,
truncated: bool,
selected_row: usize,
selected_col: usize,
active_filter: Option<state::ActiveFilter>,
},
}
enum OperationResult {
Completed(OperationPayload),
Failed(String),
Canceled,
TimedOut,
}
struct OperationMessage {
id: OperationId,
result: OperationResult,
}
struct Operations {
source: Arc<DataSource>,
sender: tokio::sync::mpsc::UnboundedSender<OperationMessage>,
receiver: tokio::sync::mpsc::UnboundedReceiver<OperationMessage>,
cancellation: Option<OperationCancellation>,
active_id: Option<OperationId>,
impact: OperationImpact,
write_safety: WriteSafety,
quit_after_draw: bool,
restoration_warning: Option<String>,
}
#[derive(Clone, Copy, Default)]
enum OperationImpact {
#[default]
Read,
Write,
CommittedWriteRefresh,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum WriteSafety {
#[default]
Clear,
InFlight,
CommittedRefresh,
CommittedNotice {
was_drawn: bool,
},
Indeterminate {
was_drawn: bool,
},
}
impl Operations {
fn new(source: Arc<DataSource>) -> Self {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
Self {
source,
sender,
receiver,
cancellation: None,
active_id: None,
impact: OperationImpact::Read,
write_safety: WriteSafety::Clear,
quit_after_draw: false,
restoration_warning: None,
}
}
fn start<F>(&mut self, app: &mut AppState, label: impl Into<String>, future: F) -> bool
where
F: Future<Output = Result<OperationPayload, crate::db::error::DbError>> + Send + 'static,
{
self.start_with_impact(app, label, OperationImpact::Read, future)
}
fn start_with_impact<F>(
&mut self,
app: &mut AppState,
label: impl Into<String>,
impact: OperationImpact,
future: F,
) -> bool
where
F: Future<Output = Result<OperationPayload, crate::db::error::DbError>> + Send + 'static,
{
if !self.can_start(impact) {
app.status = match self.write_safety {
WriteSafety::Indeterminate { .. } => {
"write outcome is indeterminate; reconnect or restart and inspect state; writes are disabled for this session".to_string()
}
WriteSafety::CommittedRefresh => {
"the write committed; wait for its refresh to finish".to_string()
}
WriteSafety::CommittedNotice { .. } => {
"the write completed; review its outcome before continuing".to_string()
}
WriteSafety::InFlight | WriteSafety::Clear => {
"write operation is still in progress; wait for its outcome before continuing"
.to_string()
}
};
if let Some(operation) = &mut app.operation {
operation.label = app.status.clone();
}
return false;
}
self.cancel_active();
let timeout = Duration::from_millis(app.config.foreground_timeout_ms);
let (mut context, cancellation) = OperationContext::new(timeout);
let id = context.id();
debug_assert_eq!(id, cancellation.id());
app.operation = Some(state::ForegroundOperation {
id,
label: label.into(),
started: Instant::now(),
});
self.cancellation = Some(cancellation);
self.active_id = Some(id);
self.impact = impact;
if matches!(impact, OperationImpact::Write) {
self.write_safety = WriteSafety::InFlight;
} else if matches!(impact, OperationImpact::CommittedWriteRefresh) {
self.write_safety = WriteSafety::CommittedRefresh;
}
let sender = self.sender.clone();
let source = Arc::clone(&self.source);
let interrupted = context.interruption_flag();
tokio::spawn(async move {
let operation = async move {
source.prepare_operation(interrupted).await?;
future.await
};
let result = match context.run(operation).await {
Ok(Ok(payload)) => OperationResult::Completed(payload),
Ok(Err(error)) => OperationResult::Failed(error.to_string()),
Err(OperationFailure::Canceled) => OperationResult::Canceled,
Err(OperationFailure::TimedOut) => OperationResult::TimedOut,
};
let _ = sender.send(OperationMessage { id, result });
});
true
}
fn cancel_active(&mut self) -> bool {
if let Some(cancellation) = self.cancellation.take() {
cancellation.cancel();
true
} else {
false
}
}
fn can_start(&self, impact: OperationImpact) -> bool {
match self.write_safety {
WriteSafety::Clear => true,
WriteSafety::InFlight | WriteSafety::CommittedNotice { .. } => {
matches!(impact, OperationImpact::CommittedWriteRefresh)
}
WriteSafety::CommittedRefresh => false,
WriteSafety::Indeterminate { was_drawn: true } => {
matches!(impact, OperationImpact::Read)
}
WriteSafety::Indeterminate { was_drawn: false } => false,
}
}
fn after_draw(&mut self, app: &mut AppState) {
self.write_safety = match self.write_safety {
WriteSafety::CommittedNotice { was_drawn: false } => WriteSafety::Clear,
WriteSafety::Indeterminate { was_drawn: false } => {
WriteSafety::Indeterminate { was_drawn: true }
}
safety => safety,
};
if self.quit_after_draw {
self.quit_after_draw = false;
dispatch(app, AppEvent::Quit);
}
}
fn record_interrupted_impact(&mut self, impact: OperationImpact) {
self.write_safety = match impact {
OperationImpact::Write => WriteSafety::Indeterminate { was_drawn: false },
OperationImpact::CommittedWriteRefresh => {
WriteSafety::CommittedNotice { was_drawn: false }
}
OperationImpact::Read => self.write_safety,
};
}
fn record_failed_impact(&mut self, impact: OperationImpact) {
self.record_interrupted_impact(impact);
}
fn record_completed_impact(&mut self, impact: OperationImpact) {
self.write_safety = match impact {
OperationImpact::Write | OperationImpact::CommittedWriteRefresh => {
WriteSafety::CommittedNotice { was_drawn: false }
}
OperationImpact::Read => self.write_safety,
};
}
fn take_cleanup_warning(&mut self) -> Option<String> {
self.restoration_warning.take().or_else(|| {
match self.write_safety {
WriteSafety::Clear => None,
WriteSafety::InFlight | WriteSafety::Indeterminate { .. } => Some(
"tuible: the event loop exited while a write was in flight or indeterminate; reconnect or restart and inspect database state before making further writes"
.to_string(),
),
WriteSafety::CommittedRefresh | WriteSafety::CommittedNotice { .. } => Some(
"tuible: the write committed, but the event loop exited before its status was fully presented; inspect database state"
.to_string(),
),
}
})
}
fn start_startup(&mut self, app: &mut AppState) {
let source = Arc::clone(&self.source);
let limit = app.config.row_limit as i64;
self.start(app, "Loading database", async move {
let tables = source.list_tables().await?;
let catalog = source.schema_catalog().await?;
let preview = if source.auto_preview() {
if let Some(table) = tables.first() {
Some((table.clone(), source.fetch_rows(table, limit, 0).await?))
} else {
None
}
} else {
None
};
Ok(OperationPayload::Startup(StartupData {
tables,
catalog,
preview,
}))
});
}
}
fn apply_config(app: &mut AppState, config: anyhow::Result<Config>) {
match config {
Ok(config) => {
let history_enabled = config.query_history_enabled;
app.config = config;
app.set_history_recording_enabled(history_enabled);
}
Err(error) => {
app.config_writable = false;
app.config.query_history_enabled = false;
app.set_history_recording_enabled(false);
app.status = format!("config: {error}; settings are read-only until repaired");
}
}
}
struct TerminalSession {
raw_mode: bool,
alternate_screen: bool,
}
impl TerminalSession {
fn enter(stdout: &mut io::Stdout) -> io::Result<Self> {
enable_raw_mode()?;
let mut session = Self {
raw_mode: true,
alternate_screen: false,
};
if let Err(error) = execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
) {
let _ = session.restore();
return Err(error);
}
session.alternate_screen = true;
Ok(session)
}
fn restore(&mut self) -> io::Result<()> {
let mut error = None;
if self.alternate_screen {
match execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste,
Show
) {
Ok(()) => self.alternate_screen = false,
Err(err) => error = Some(err),
}
}
if self.raw_mode {
match disable_raw_mode() {
Ok(()) => self.raw_mode = false,
Err(err) if error.is_none() => error = Some(err),
Err(_) => {}
}
}
error.map_or(Ok(()), Err)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = self.restore();
}
}
async fn event_loop<B: Backend<Error = io::Error>>(
terminal: &mut Terminal<B>,
app: &mut AppState,
operations: &mut Operations,
) -> io::Result<()> {
let mut mouse_state = MouseState::default();
while !app.should_quit {
terminal.draw(|frame| render::draw(frame, app))?;
operations.after_draw(app);
if app.should_quit {
break;
}
while let Ok(message) = operations.receiver.try_recv() {
apply_operation_message(app, operations, message);
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => handle_key(app, operations, key),
Event::Mouse(mouse) => {
handle_mouse(app, operations, terminal.size()?, mouse, &mut mouse_state)
}
Event::Paste(text) => handle_paste(app, text),
_ => {}
}
}
}
Ok(())
}
#[derive(Default)]
struct MouseState {
last_click: Option<(Panel, u16, u16, std::time::Instant)>,
}
impl MouseState {
fn register_click(&mut self, panel: Panel, x: u16, y: u16) -> bool {
let now = std::time::Instant::now();
let is_double = self
.last_click
.is_some_and(|(last_panel, last_x, last_y, at)| {
panel == last_panel
&& x == last_x
&& y == last_y
&& now.duration_since(at) <= Duration::from_millis(450)
});
self.last_click = (!is_double).then_some((panel, x, y, now));
is_double
}
}
fn handle_paste(app: &mut AppState, text: String) {
if let Overlay::Edit { text: input, .. } = &mut app.overlay {
input.push_str(&text);
} else if matches!(app.overlay, Overlay::History(_)) {
app.insert_history_search(&text);
} else if app.overlay == Overlay::None
&& app.focus == Panel::Sql
&& app.editor_tab == EditorTab::Filters
&& app.filters.selected_field() == FilterField::Value
&& app.filters.selected_row().operator.needs_value()
{
app.filters.selected_row_mut().value.push_str(&text);
} else if app.overlay == Overlay::None
&& app.focus == Panel::Sql
&& app.sql_mode == SqlMode::Insert
{
app.sql.insert_str(&text);
app.refresh_completion(false);
}
}
fn handle_key(app: &mut AppState, operations: &mut Operations, key: KeyEvent) {
if key.kind == KeyEventKind::Release {
return;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
request_quit(app, operations);
return;
}
if key.code == KeyCode::Esc && app.overlay == Overlay::None && operations.cancel_active() {
let elapsed = app
.operation
.as_ref()
.map_or(Duration::ZERO, |operation| operation.started.elapsed());
app.status = operation_interruption_status(operations.impact, false, elapsed);
if let Some(operation) = &mut app.operation {
operation.label = app.status.clone();
}
return;
}
if handle_history_key(app, key) {
return;
}
match &app.overlay {
Overlay::Help | Overlay::Schema { .. } | Overlay::Value { .. } => {
if app.config.shortcuts.matches(ShortcutAction::Help, key) {
dispatch(app, AppEvent::ToggleHelp);
} else {
dispatch(app, AppEvent::CloseOverlay);
}
return;
}
Overlay::Edit { .. } => {
match key.code {
KeyCode::Esc => dispatch(app, AppEvent::CloseOverlay),
KeyCode::Enter => commit_value(app, operations),
KeyCode::Backspace => dispatch(app, AppEvent::InputBackspace),
KeyCode::Char(c) => dispatch(app, AppEvent::InputChar(c)),
_ => {}
}
return;
}
Overlay::ConfirmSql { sql } => {
let sql = sql.clone();
match key.code {
KeyCode::Esc => dispatch(app, AppEvent::CloseOverlay),
KeyCode::Enter => {
dispatch(app, AppEvent::CloseOverlay);
execute_sql_now(app, operations, &sql);
}
_ => {}
}
return;
}
Overlay::Settings { selected } => {
let selected = *selected;
match key.code {
KeyCode::Esc => dispatch(app, AppEvent::CloseOverlay),
KeyCode::Up | KeyCode::Char('k') => {
app.overlay = Overlay::Settings {
selected: selected.saturating_sub(1),
};
}
KeyCode::Down | KeyCode::Char('j') => {
app.overlay = Overlay::Settings {
selected: (selected + 1).min(5),
};
}
KeyCode::Enter => {
if selected < 3 {
let (target, text) = if selected == 0 {
(EditTarget::RowLimit, app.config.row_limit.to_string())
} else if selected == 1 {
(EditTarget::ColWidth, app.config.col_width.to_string())
} else {
(
EditTarget::ForegroundTimeout,
app.config.foreground_timeout_ms.to_string(),
)
};
app.overlay = Overlay::Edit {
text,
target,
cell: None,
};
} else if selected == 3 {
let previous = app.config.clone();
app.config.theme = app.config.theme.next();
save_config_change(app, previous, "theme changed");
} else if selected == 4 {
toggle_editor(app);
} else {
toggle_query_history(app);
}
}
_ => {}
}
return;
}
Overlay::Copy { format, headers } => {
let (format, headers) = (*format, *headers);
match key.code {
KeyCode::Esc => dispatch(app, AppEvent::CloseOverlay),
KeyCode::Left | KeyCode::Char('h') => {
app.overlay = Overlay::Copy {
format: format.previous(),
headers,
};
}
KeyCode::Right | KeyCode::Char('l') => {
app.overlay = Overlay::Copy {
format: format.next(),
headers,
};
}
KeyCode::Char(' ') => {
app.overlay = Overlay::Copy {
format,
headers: !headers,
};
}
KeyCode::Enter => copy_advanced(app, format, headers),
_ => {}
}
return;
}
Overlay::History(_) => return,
Overlay::None => {}
}
if app.focus == Panel::Grid
&& app.data_tab == state::DataTab::Rows
&& key.code == KeyCode::Char('o')
&& key.modifiers == KeyModifiers::NONE
{
dispatch(app, AppEvent::CycleSort);
return;
}
if app.focus == Panel::Sql && app.editor_tab == EditorTab::Filters {
let shortcuts = app.config.shortcuts.clone();
if shortcuts.matches(ShortcutAction::NextTab, key) {
switch_tab(app, false);
return;
}
if shortcuts.matches(ShortcutAction::PreviousTab, key) {
switch_tab(app, true);
return;
}
if handle_filter_key(app, operations, key) {
return;
}
}
let normal_mode = app.focus != Panel::Sql || app.sql_mode == SqlMode::Normal;
if normal_mode {
let shortcuts = app.config.shortcuts.clone();
if shortcuts.matches(ShortcutAction::Quit, key) {
request_quit(app, operations);
return;
}
if shortcuts.matches(ShortcutAction::Help, key) {
dispatch(app, AppEvent::ToggleHelp);
return;
}
if shortcuts.matches(ShortcutAction::NextPanel, key) {
focus_next(app);
return;
}
if shortcuts.matches(ShortcutAction::PreviousPanel, key) {
focus_previous(app);
return;
}
if shortcuts.matches(ShortcutAction::NextTab, key) {
switch_tab(app, false);
return;
}
if shortcuts.matches(ShortcutAction::PreviousTab, key) {
switch_tab(app, true);
return;
}
if shortcuts.matches(ShortcutAction::Filter, key) {
focus_filter_panel(app);
return;
}
if shortcuts.matches(ShortcutAction::ToggleEditor, key) {
toggle_editor(app);
return;
}
if shortcuts.matches(ShortcutAction::ToggleTables, key) {
toggle_sidebar(app);
return;
}
if shortcuts.matches(ShortcutAction::ToggleRecord, key) {
toggle_inspector(app);
return;
}
if shortcuts.matches(ShortcutAction::Settings, key) {
app.overlay = Overlay::Settings { selected: 0 };
return;
}
if shortcuts.matches(ShortcutAction::AdvancedCopy, key)
&& app.focus == Panel::Grid
&& app.data_tab == state::DataTab::Rows
{
open_advanced_copy(app);
return;
}
}
if app.focus == Panel::Sql && app.editor_tab == EditorTab::Filters {
return;
}
if app.focus == Panel::Sql {
if key.code == KeyCode::Char(' ') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.sql_mode = SqlMode::Insert;
open_completions(app);
return;
}
match app.sql_mode {
SqlMode::Insert => match key.code {
KeyCode::Esc if app.completion.is_some() => app.completion = None,
KeyCode::Esc => app.sql_mode = SqlMode::Normal,
KeyCode::Tab => {
if !app.accept_completion() {
app.sql.insert_str(" ");
app.refresh_completion(false);
}
}
KeyCode::Enter => {
app.sql.insert('\n');
app.completion = None;
}
KeyCode::Backspace => dispatch(app, AppEvent::InputBackspace),
KeyCode::Left => {
app.sql.move_left();
app.refresh_completion(false);
}
KeyCode::Right => {
app.sql.move_right();
app.refresh_completion(false);
}
KeyCode::Up if app.completion.is_some() => app.move_completion(-1),
KeyCode::Down if app.completion.is_some() => app.move_completion(1),
KeyCode::Up => {
app.sql.move_up();
app.refresh_completion(false);
}
KeyCode::Down => {
app.sql.move_down();
app.refresh_completion(false);
}
KeyCode::Home => {
app.sql.move_home();
app.refresh_completion(false);
}
KeyCode::End => {
app.sql.move_end();
app.refresh_completion(false);
}
KeyCode::Char(c) => dispatch(app, AppEvent::InputChar(c)),
_ => {}
},
SqlMode::Normal => match key.code {
KeyCode::Enter => run_sql(app, operations),
KeyCode::Char('i') => {
app.sql_mode = SqlMode::Insert;
app.refresh_completion(false);
}
KeyCode::Char('a') => {
app.sql.move_right();
app.sql_mode = SqlMode::Insert;
app.refresh_completion(false);
}
KeyCode::Left | KeyCode::Char('h') => app.sql.move_left(),
KeyCode::Right | KeyCode::Char('l') => app.sql.move_right(),
KeyCode::Up | KeyCode::Char('k') => app.sql.move_up(),
KeyCode::Down | KeyCode::Char('j') => app.sql.move_down(),
KeyCode::Home | KeyCode::Char('0') => app.sql.move_home(),
KeyCode::End | KeyCode::Char('$') => app.sql.move_end(),
KeyCode::Char('1') => {
app.sidebar_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Tables));
}
KeyCode::Char('2') => app.config.editor_visible = true,
KeyCode::Char('3') => dispatch(app, AppEvent::FocusPanel(Panel::Grid)),
KeyCode::Char('4') => {
app.inspector_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Inspector));
}
_ => {}
},
}
return;
}
match key.code {
KeyCode::Char('1') => {
app.sidebar_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Tables));
}
KeyCode::Char('2') => {
app.config.editor_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Sql));
}
KeyCode::Char('3') => dispatch(app, AppEvent::FocusPanel(Panel::Grid)),
KeyCode::Char('4') => {
app.inspector_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Inspector));
}
KeyCode::Down | KeyCode::Char('j') => {
dispatch(app, AppEvent::MoveDown);
if app.focus == Panel::Tables && operations.source.auto_preview() {
load_selected_table(app, operations);
}
}
KeyCode::Up | KeyCode::Char('k') => {
dispatch(app, AppEvent::MoveUp);
if app.focus == Panel::Tables && operations.source.auto_preview() {
load_selected_table(app, operations);
}
}
KeyCode::Left | KeyCode::Char('h') => dispatch(app, AppEvent::MoveLeft),
KeyCode::Right | KeyCode::Char('l') => dispatch(app, AppEvent::MoveRight),
KeyCode::Char('g') => dispatch(app, AppEvent::JumpTop),
KeyCode::Char('G') => dispatch(app, AppEvent::JumpBottom),
KeyCode::PageDown => next_page(app, operations),
KeyCode::PageUp => previous_page(app, operations),
KeyCode::Char('r') => refresh(app, operations),
KeyCode::Char('s') => load_schema(app, operations),
KeyCode::Char('y') => yank_cell(app),
KeyCode::Char('e') | KeyCode::Char('i') => dispatch(app, AppEvent::EditStart),
KeyCode::Char('v') if app.focus == Panel::Grid && app.data_tab == state::DataTab::Rows => {
app.grid.visual = Visual::Column;
dispatch(app, AppEvent::Status("visual column".to_string()));
}
KeyCode::Char('V') if app.focus == Panel::Grid && app.data_tab == state::DataTab::Rows => {
app.grid.visual = Visual::Rows {
anchor: app.grid.selected_row,
};
dispatch(app, AppEvent::Status("visual rows".to_string()));
}
KeyCode::Esc if app.focus == Panel::Grid => {
app.grid.visual = Visual::None;
dispatch(app, AppEvent::Status("selection cleared".to_string()));
}
KeyCode::Char('J') if app.focus == Panel::Grid => {
app.inspector_scroll = app.inspector_scroll.saturating_add(3);
}
KeyCode::Char('K') if app.focus == Panel::Grid => {
app.inspector_scroll = app.inspector_scroll.saturating_sub(3);
}
KeyCode::Enter => match app.focus {
Panel::Tables => {
load_selected_table(app, operations);
dispatch(app, AppEvent::FocusPanel(Panel::Grid));
}
Panel::Grid if app.data_tab == state::DataTab::Rows => activate_grid_cell(app),
Panel::Grid => {}
Panel::Sql | Panel::Inspector => {}
},
_ => {}
}
}
fn request_quit(app: &mut AppState, operations: &mut Operations) {
if operations.cancel_active() {
let elapsed = app
.operation
.as_ref()
.map_or(Duration::ZERO, |operation| operation.started.elapsed());
app.status = operation_interruption_status(operations.impact, false, elapsed);
if let Some(operation) = &mut app.operation {
operation.label = app.status.clone();
}
}
operations.restoration_warning = match operations.write_safety {
WriteSafety::Clear => None,
WriteSafety::Indeterminate { .. } => Some(
"tuible: write outcome is indeterminate; reconnect or restart and inspect database state before making further writes"
.to_string(),
),
WriteSafety::InFlight => Some(
"tuible: write cancellation was requested and its outcome is indeterminate; reconnect or restart and inspect database state before making further writes"
.to_string(),
),
WriteSafety::CommittedRefresh | WriteSafety::CommittedNotice { .. } => Some(
"tuible: the write committed; inspect database state if its refresh did not finish"
.to_string(),
),
};
let warning_needs_draw = matches!(
operations.write_safety,
WriteSafety::InFlight
| WriteSafety::CommittedRefresh
| WriteSafety::CommittedNotice { was_drawn: false }
| WriteSafety::Indeterminate { was_drawn: false }
);
if warning_needs_draw {
operations.quit_after_draw = true;
} else {
dispatch(app, AppEvent::Quit);
}
}
fn handle_history_key(app: &mut AppState, key: KeyEvent) -> bool {
if matches!(app.overlay, Overlay::History(_)) {
match key.code {
KeyCode::Esc => dispatch(app, AppEvent::CloseOverlay),
KeyCode::Up => app.move_history_selection(-1),
KeyCode::Down => app.move_history_selection(1),
KeyCode::Enter => {
app.accept_history_selection();
}
KeyCode::Backspace => app.backspace_history_search(),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
app.insert_history_search(&character.to_string());
}
_ => {}
}
return true;
}
let control_r = matches!(key.code, KeyCode::Char(character) if character.eq_ignore_ascii_case(&'r'))
&& key.modifiers.contains(KeyModifiers::CONTROL);
if control_r && app.overlay != Overlay::None {
return true;
}
if control_r && app.focus == Panel::Sql && app.editor_tab == EditorTab::Sql {
app.open_history_search();
return true;
}
false
}
fn handle_filter_key(app: &mut AppState, operations: &mut Operations, key: KeyEvent) -> bool {
let shortcuts = app.config.shortcuts.clone();
if shortcuts.matches(ShortcutAction::NextTab, key) {
switch_tab(app, false);
return true;
}
if shortcuts.matches(ShortcutAction::PreviousTab, key) {
switch_tab(app, true);
return true;
}
let field = app.filters.selected_field();
let needs_value = app.filters.selected_row().operator.needs_value();
if field == FilterField::Value && needs_value {
match key.code {
KeyCode::Tab => app.filters.next_field(),
KeyCode::BackTab | KeyCode::Esc => app.filters.previous_field(),
KeyCode::Backspace => {
app.filters.selected_row_mut().value.pop();
}
KeyCode::Enter => apply_filters(app, operations),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
app.filters.selected_row_mut().value.push(character);
}
_ => return false,
}
return true;
}
match key.code {
KeyCode::Tab => app.filters.next_field(),
KeyCode::BackTab => app.filters.previous_field(),
KeyCode::Up | KeyCode::Char('k') => app.filters.previous_row(),
KeyCode::Down | KeyCode::Char('j') => app.filters.next_row(),
KeyCode::Left | KeyCode::Char('h') => adjust_filter_control(app, false),
KeyCode::Right | KeyCode::Char('l') => adjust_filter_control(app, true),
KeyCode::Char(' ') if field == FilterField::Enabled => {
let row = app.filters.selected_row_mut();
row.enabled = !row.enabled;
}
KeyCode::Char('+') => add_filter_row(app),
KeyCode::Char('-') => remove_filter_row(app),
KeyCode::Char('A') => apply_filters(app, operations),
KeyCode::Char('c') => clear_filters(app, operations),
KeyCode::Enter => match field {
FilterField::Enabled => {
let row = app.filters.selected_row_mut();
row.enabled = !row.enabled;
}
FilterField::Column | FilterField::Operator => adjust_filter_control(app, true),
FilterField::Value | FilterField::Apply => apply_filters(app, operations),
FilterField::Remove => remove_filter_row(app),
FilterField::Add => add_filter_row(app),
},
KeyCode::Char('1') => {
app.sidebar_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Tables));
}
KeyCode::Char('2') => {}
KeyCode::Char('3') => dispatch(app, AppEvent::FocusPanel(Panel::Grid)),
KeyCode::Char('4') => {
app.inspector_visible = true;
dispatch(app, AppEvent::FocusPanel(Panel::Inspector));
}
_ => return false,
}
true
}
fn adjust_filter_control(app: &mut AppState, forward: bool) {
let field = app.filters.selected_field();
match field {
FilterField::Column if !app.grid.columns.is_empty() => {
let column_count = app.grid.columns.len();
let current = app.filters.selected_row().column_index;
let index = if forward {
(current + 1) % column_count
} else {
(current + column_count - 1) % column_count
};
let name = app.grid.columns[index].name.clone();
app.filters.selected_row_mut().set_column(index, name);
app.filters
.capture_column_types(&app.grid.columns, &app.grid.rows);
}
FilterField::Operator => {
let row = app.filters.selected_row_mut();
row.operator = if forward {
row.operator.next()
} else {
row.operator.previous()
};
if !row.operator.needs_value() {
row.value.clear();
}
}
_ if forward => app.filters.next_field(),
_ => app.filters.previous_field(),
}
}
fn add_filter_row(app: &mut AppState) {
app.filters.add_row();
if let Some(column) = app.grid.columns.first() {
app.filters
.selected_row_mut()
.set_column(0, column.name.clone());
app.filters
.capture_column_types(&app.grid.columns, &app.grid.rows);
}
}
fn reset_filters(app: &mut AppState) {
app.filters = state::FilterState::default();
if let Some(column) = app.grid.columns.first() {
app.filters
.selected_row_mut()
.set_column(0, column.name.clone());
app.filters
.capture_column_types(&app.grid.columns, &app.grid.rows);
}
}
fn remove_filter_row(app: &mut AppState) {
app.filters.remove_selected_row();
if app.filters.selected_row().column_name.is_none()
&& let Some(column) = app.grid.columns.first()
{
app.filters
.selected_row_mut()
.set_column(0, column.name.clone());
app.filters
.capture_column_types(&app.grid.columns, &app.grid.rows);
}
}
fn focus_next(app: &mut AppState) {
dispatch(app, AppEvent::ToggleFocus);
}
fn focus_previous(app: &mut AppState) {
dispatch(app, AppEvent::FocusPrev);
}
fn switch_tab(app: &mut AppState, reverse: bool) {
let status = match app.focus {
Panel::Grid => {
app.data_tab.toggle();
match app.data_tab {
state::DataTab::Rows => "data tab",
state::DataTab::Schema => "schema tab",
}
}
Panel::Inspector => {
app.record_tab.toggle();
match app.record_tab {
state::RecordTab::Fields => "record fields tab",
state::RecordTab::Json => "record JSON tab",
}
}
Panel::Sql => {
app.editor_tab = if reverse {
app.editor_tab.previous()
} else {
app.editor_tab.next()
};
app.sql_mode = SqlMode::Normal;
app.completion = None;
match app.editor_tab {
EditorTab::Filters => "filters tab",
EditorTab::Sql => "SQL tab",
}
}
Panel::Tables => "no alternate tab in this pane",
};
dispatch(app, AppEvent::Status(status.to_string()));
}
fn toggle_sidebar(app: &mut AppState) {
app.sidebar_visible = !app.sidebar_visible;
if !app.sidebar_visible && app.focus == Panel::Tables {
let focus = if app.config.editor_visible {
Panel::Sql
} else {
Panel::Grid
};
dispatch(app, AppEvent::FocusPanel(focus));
}
}
fn toggle_inspector(app: &mut AppState) {
app.inspector_visible = !app.inspector_visible;
if !app.inspector_visible && app.focus == Panel::Inspector {
dispatch(app, AppEvent::FocusPanel(Panel::Grid));
}
}
fn toggle_editor(app: &mut AppState) {
let previous = app.config.clone();
app.config.editor_visible = !app.config.editor_visible;
if !app.config.editor_visible && app.focus == Panel::Sql {
app.sql_mode = SqlMode::Normal;
app.completion = None;
dispatch(app, AppEvent::FocusPanel(Panel::Grid));
}
let message = if app.config.editor_visible {
"pane 2 shown"
} else {
"pane 2 hidden"
};
save_config_change(app, previous, message);
}
fn toggle_query_history(app: &mut AppState) {
toggle_query_history_with(app, Config::save);
}
fn toggle_query_history_with(
app: &mut AppState,
save: impl FnOnce(&mut Config) -> anyhow::Result<()>,
) {
let previous = app.config.clone();
let enabled = !app.history_recording_enabled();
app.config.query_history_enabled = enabled;
app.set_history_recording_enabled(enabled);
let message = if enabled {
"query history recording enabled"
} else {
"query history recording disabled"
};
if !save_config_change_with(app, previous, message, save) {
app.set_history_recording_enabled(false);
}
}
fn save_config_change(app: &mut AppState, previous: Config, message: &str) -> bool {
save_config_change_with(app, previous, message, Config::save)
}
fn save_config_change_with(
app: &mut AppState,
previous: Config,
message: &str,
save: impl FnOnce(&mut Config) -> anyhow::Result<()>,
) -> bool {
if !app.config_writable {
app.config = previous;
dispatch(
app,
AppEvent::Error("configuration is invalid; repair it before saving".to_string()),
);
return false;
}
if let Err(error) = save(&mut app.config) {
app.config = previous;
dispatch(app, AppEvent::Error(format!("config: {error}")));
false
} else {
dispatch(app, AppEvent::Status(message.to_string()));
true
}
}
fn focus_filter_panel(app: &mut AppState) {
app.config.editor_visible = true;
app.editor_tab = EditorTab::Filters;
app.sql_mode = SqlMode::Normal;
app.completion = None;
dispatch(app, AppEvent::FocusPanel(Panel::Sql));
}
fn apply_filters(app: &mut AppState, operations: &mut Operations) {
if app.filters.active_rows().next().is_none() {
clear_filters(app, operations);
return;
}
let Some((table, sql)) = build_filters_sql(app) else {
dispatch(
app,
AppEvent::Error("complete each enabled filter before applying".to_string()),
);
return;
};
execute_filter_sql(app, operations, table, sql);
}
fn clear_filters(app: &mut AppState, operations: &mut Operations) {
let Some(table) = app.loaded_table.clone() else {
reset_filters(app);
app.active_filter = None;
dispatch(app, AppEvent::Status("filters cleared".to_string()));
return;
};
let source = Arc::clone(&operations.source);
let limit = app.config.row_limit as i64;
let task_table = table.clone();
operations.start(app, format!("Clearing filters for {table}"), async move {
let page = source.fetch_rows(&task_table, limit, 0).await?;
Ok(OperationPayload::Rows {
table: task_table,
page,
schema: None,
clear_filters: true,
})
});
}
fn execute_filter_sql(app: &mut AppState, operations: &mut Operations, table: String, sql: String) {
app.sql.text = sql.clone();
app.sql.cursor = sql.chars().count();
app.sql_mode = SqlMode::Normal;
app.completion = None;
app.data_tab = state::DataTab::Rows;
dispatch(app, AppEvent::CloseOverlay);
let source = Arc::clone(&operations.source);
let task_table = table.clone();
let task_sql = sql.clone();
operations.start(app, format!("Filtering {table}"), async move {
let page = source
.execute_table_filter(&task_table, &task_sql, MAX_QUERY_ROWS)
.await?;
let fallback = if page.is_none() {
Some(source.execute_sql(&task_sql, MAX_QUERY_ROWS).await?)
} else {
None
};
Ok(OperationPayload::Filter {
table: task_table,
sql: task_sql,
page,
fallback,
})
});
}
fn build_filters_sql(app: &AppState) -> Option<(String, String)> {
let table = app.loaded_table.clone()?;
let expressions: Option<Vec<String>> = app
.filters
.active_rows()
.map(|filter| {
let column = filter.column_name.as_deref()?;
build_filter_expression(
app,
&table,
column,
filter.column_type,
filter.operator,
&filter.value,
)
})
.collect();
let expressions = expressions?;
if expressions.is_empty() {
return None;
}
let sql = format!(
"SELECT * FROM {} WHERE {}",
quote_filter_identifier(&table),
expressions.join(" AND ")
);
Some((table, sql))
}
fn build_filter_expression(
app: &AppState,
table: &str,
column: &str,
column_type: Option<FilterColumnType>,
operator: FilterOperator,
value: &str,
) -> Option<String> {
let quoted_column = quote_filter_identifier(column);
let expression = match operator {
FilterOperator::Contains | FilterOperator::NotContains => {
let escaped = value.replace('\'', "''");
let contains = if app.backend_name == "DynamoDB" {
format!("contains({quoted_column}, '{escaped}')")
} else {
let escaped = escaped
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
format!("CAST({quoted_column} AS TEXT) LIKE '%{escaped}%' ESCAPE '\\'")
};
if operator == FilterOperator::NotContains {
format!("NOT ({contains})")
} else {
contains
}
}
FilterOperator::In | FilterOperator::NotIn => {
let values: Vec<String> = value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| filter_literal(app, table, column, column_type, value))
.collect();
if values.is_empty() {
return None;
}
let keyword = if operator == FilterOperator::NotIn {
"NOT IN"
} else {
"IN"
};
let (open, close) = if app.backend_name == "DynamoDB" {
('[', ']')
} else {
('(', ')')
};
format!(
"{quoted_column} {keyword} {open}{}{close}",
values.join(", ")
)
}
FilterOperator::IsNull => format!("{quoted_column} IS NULL"),
FilterOperator::IsNotNull => format!("{quoted_column} IS NOT NULL"),
operator => {
let symbol = match operator {
FilterOperator::Equal => "=",
FilterOperator::NotEqual => "<>",
FilterOperator::Greater => ">",
FilterOperator::GreaterOrEqual => ">=",
FilterOperator::Less => "<",
FilterOperator::LessOrEqual => "<=",
FilterOperator::Contains
| FilterOperator::NotContains
| FilterOperator::In
| FilterOperator::NotIn
| FilterOperator::IsNull
| FilterOperator::IsNotNull => return None,
};
let literal = filter_literal(app, table, column, column_type, value);
format!("{quoted_column} {symbol} {literal}")
}
};
Some(expression)
}
fn filter_literal(
app: &AppState,
table: &str,
column: &str,
stored_type: Option<FilterColumnType>,
value: &str,
) -> String {
let schema_type = app
.schemas
.get(table)
.and_then(|schema| schema.iter().find(|candidate| candidate.name == column))
.map(|column| column.col_type.to_ascii_uppercase());
let sample = app
.grid
.columns
.iter()
.position(|candidate| candidate.name == column)
.and_then(|index| {
app.grid
.rows
.iter()
.filter_map(|row| row.values.get(index))
.find(|value| !matches!(value, Value::Null))
});
let numeric_sample = sample
.is_some_and(|value| matches!(value, Value::Int(_) | Value::Float(_) | Value::Decimal(_)));
let numeric = stored_type == Some(FilterColumnType::Number)
|| numeric_sample
|| schema_type.as_deref().is_some_and(|column_type| {
column_type == "N"
|| column_type.contains("INT")
|| column_type.contains("REAL")
|| column_type.contains("FLOA")
|| column_type.contains("DOUB")
|| column_type.contains("DEC")
|| column_type.contains("NUM")
});
let boolean = stored_type == Some(FilterColumnType::Boolean)
|| sample.is_some_and(|value| matches!(value, Value::Bool(_)))
|| schema_type
.as_deref()
.is_some_and(|column_type| column_type.contains("BOOL"));
if numeric && value.parse::<serde_json::Number>().is_ok() {
value.to_string()
} else if boolean && (value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false"))
{
value.to_ascii_lowercase()
} else {
format!("'{}'", value.replace('\'', "''"))
}
}
fn quote_filter_identifier(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
fn activate_grid_cell(app: &mut AppState) {
if !app.read_only && app.loaded_table.is_some() && app.selected_rowid().is_some() {
dispatch(app, AppEvent::EditStart);
return;
}
let Some(text) = app.selected_cell_value().map(|value| match value {
crate::db::model::Value::Bytes(bytes) => bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
value => value.to_string(),
}) else {
return;
};
let column = app
.selected_column_name()
.map_or_else(|| "cell".to_string(), str::to_string);
app.overlay = Overlay::Value {
title: format!(
"{column} - row {}",
app.grid.page_offset + app.grid.selected_row + 1
),
text,
};
}
fn open_completions(app: &mut AppState) {
app.refresh_completion(true);
if app.completion.is_none() {
dispatch(app, AppEvent::Status("no completions".to_string()));
}
}
fn handle_mouse(
app: &mut AppState,
operations: &mut Operations,
size: Size,
mouse: MouseEvent,
mouse_state: &mut MouseState,
) {
let l = layout::compute_with_panels(
Rect::new(0, 0, size.width, size.height),
app.sidebar_visible,
app.inspector_visible,
app.config.editor_visible,
);
let panel = layout::hit_test(&l, mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
let Some(panel) = panel else { return };
let double_click = mouse_state.register_click(panel, mouse.column, mouse.row);
if app.overlay != Overlay::None {
dispatch(app, AppEvent::CloseOverlay);
return;
}
dispatch(app, AppEvent::FocusPanel(panel));
match panel {
Panel::Tables => {
if mouse.row <= l.tables.y || mouse.row >= l.tables.bottom().saturating_sub(1) {
return;
}
let inner_y = mouse.row.saturating_sub(l.tables.y + 1) as usize;
let index = inner_y + app.tables_state.offset();
if index < app.tables.len() {
app.selected_table = index;
if operations.source.auto_preview() || double_click {
load_selected_table(app, operations);
}
if double_click {
dispatch(app, AppEvent::FocusPanel(Panel::Grid));
}
}
}
Panel::Grid => {
if app.data_tab != state::DataTab::Rows {
return;
}
if mouse.row <= l.grid.y + 1
|| mouse.row >= l.grid.bottom().saturating_sub(1)
|| mouse.column <= l.grid.x
|| mouse.column >= l.grid.right().saturating_sub(1)
{
return;
}
let inner_y = mouse.row.saturating_sub(l.grid.y + 2) as usize;
let row = inner_y + app.grid.table_state.offset();
if row < app.grid.rows.len() {
app.grid.selected_row = row;
app.inspector_scroll = 0;
}
let inner_x = mouse.column.saturating_sub(l.grid.x + 1) as usize;
let col = app.grid.col_offset + inner_x / (app.config.col_width as usize + 1);
if col < app.grid.columns.len() {
app.grid.selected_col = col;
app.inspector_scroll = 0;
}
if double_click {
activate_grid_cell(app);
}
}
Panel::Sql => {
if select_editor_tab_at(app, l.sql, mouse.column, mouse.row) {
return;
}
if app.editor_tab != EditorTab::Filters {
return;
}
let Some((row, field)) = filter_hit_target(app, l.sql, mouse.column, mouse.row)
else {
return;
};
app.filters.select_row(row);
app.filters.select_field(field);
match field {
FilterField::Enabled => {
let filter = app.filters.selected_row_mut();
filter.enabled = !filter.enabled;
}
FilterField::Apply => apply_filters(app, operations),
FilterField::Remove => remove_filter_row(app),
FilterField::Add => add_filter_row(app),
FilterField::Column | FilterField::Operator | FilterField::Value => {}
}
}
Panel::Inspector => {}
}
}
MouseEventKind::ScrollDown => {
if let Some(panel) = panel {
dispatch(app, AppEvent::FocusPanel(panel));
if panel == Panel::Sql && app.editor_tab == EditorTab::Filters {
app.filters.next_row();
} else if panel == Panel::Inspector {
app.inspector_scroll = app.inspector_scroll.saturating_add(2);
} else {
dispatch(app, AppEvent::MoveDown);
}
}
}
MouseEventKind::ScrollUp => {
if let Some(panel) = panel {
dispatch(app, AppEvent::FocusPanel(panel));
if panel == Panel::Sql && app.editor_tab == EditorTab::Filters {
app.filters.previous_row();
} else if panel == Panel::Inspector {
app.inspector_scroll = app.inspector_scroll.saturating_sub(2);
} else {
dispatch(app, AppEvent::MoveUp);
}
}
}
_ => {}
}
}
fn select_editor_tab_at(app: &mut AppState, area: Rect, column: u16, row: u16) -> bool {
if row != area.y {
return false;
}
let title_column = column.saturating_sub(area.x.saturating_add(1));
let tab = match title_column {
4..=10 => Some(EditorTab::Filters),
14..=16 => Some(EditorTab::Sql),
_ => None,
};
if let Some(tab) = tab {
app.editor_tab = tab;
app.sql_mode = SqlMode::Normal;
app.completion = None;
return true;
}
false
}
fn filter_hit_target(
app: &AppState,
area: Rect,
column: u16,
row: u16,
) -> Option<(usize, FilterField)> {
let rows_area = layout::filter_rows_area(area);
if row < rows_area.y
|| row >= rows_area.bottom()
|| column < rows_area.x
|| column >= rows_area.right()
{
return None;
}
let visible = rows_area.height.max(1) as usize;
let selected = app.filters.selected_row_index();
let offset = selected
.saturating_sub(visible / 2)
.min(app.filters.rows().len().saturating_sub(visible));
let row_index = offset + row.saturating_sub(rows_area.y) as usize;
if row_index >= app.filters.rows().len() {
return None;
}
let row_area = Rect::new(rows_area.x, row, rows_area.width, 1);
let controls = Layout::horizontal(layout::filter_control_constraints(row_area.width))
.spacing(1)
.split(row_area);
let fields = [
FilterField::Enabled,
FilterField::Column,
FilterField::Operator,
FilterField::Value,
FilterField::Apply,
FilterField::Remove,
FilterField::Add,
];
controls
.iter()
.zip(fields)
.find(|(control, _)| column >= control.x && column < control.right())
.map(|(_, field)| (row_index, field))
}
fn load_selected_table(app: &mut AppState, operations: &mut Operations) {
let Some(table) = app.tables.get(app.selected_table).cloned() else {
return;
};
load_table_page(app, operations, table, 0);
}
fn load_table_page(app: &mut AppState, operations: &mut Operations, table: String, offset: usize) {
let source = Arc::clone(&operations.source);
let limit = app.config.row_limit as i64;
let needs_schema = !app.schemas.contains_key(&table);
let task_table = table.clone();
operations.start(app, format!("Loading {table}"), async move {
let page = source.fetch_rows(&task_table, limit, offset as i64).await?;
let schema = if needs_schema {
source.table_schema(&task_table).await.ok()
} else {
None
};
Ok(OperationPayload::Rows {
table: task_table,
page,
schema,
clear_filters: false,
})
});
}
fn next_page(app: &mut AppState, operations: &mut Operations) {
if app.focus != Panel::Grid || app.data_tab != state::DataTab::Rows || !app.grid.has_more {
return;
}
let Some(table) = app.loaded_table.clone() else {
return;
};
let offset = app.grid.page_offset + app.config.row_limit;
load_table_page(app, operations, table, offset);
}
fn previous_page(app: &mut AppState, operations: &mut Operations) {
if app.focus != Panel::Grid || app.data_tab != state::DataTab::Rows || app.grid.page_offset == 0
{
return;
}
let Some(table) = app.loaded_table.clone() else {
return;
};
let offset = app.grid.page_offset.saturating_sub(app.config.row_limit);
load_table_page(app, operations, table, offset);
}
fn refresh(app: &mut AppState, operations: &mut Operations) {
refresh_with_impact(app, operations, OperationImpact::Read);
}
fn refresh_after_committed_write(app: &mut AppState, operations: &mut Operations) {
refresh_with_impact(app, operations, OperationImpact::CommittedWriteRefresh);
}
fn refresh_with_impact(app: &mut AppState, operations: &mut Operations, impact: OperationImpact) {
let loaded = app.loaded_table.clone();
let offset = app.grid.page_offset;
let source = Arc::clone(&operations.source);
let limit = app.config.row_limit as i64;
operations.start_with_impact(app, "Refreshing database", impact, async move {
let tables = source.list_tables().await?;
let loaded = if let Some(table) = loaded.filter(|table| tables.contains(table)) {
let page = source.fetch_rows(&table, limit, offset as i64).await?;
Some((table, page))
} else {
None
};
let catalog = source.schema_catalog().await?;
Ok(OperationPayload::Refresh {
tables,
loaded,
catalog,
})
});
}
fn load_schema(app: &mut AppState, operations: &mut Operations) {
let table = match app.focus {
Panel::Grid | Panel::Inspector => app.loaded_table.clone(),
_ => app.tables.get(app.selected_table).cloned(),
};
let Some(table) = table else { return };
let source = Arc::clone(&operations.source);
let task_table = table.clone();
operations.start(app, format!("Loading schema for {table}"), async move {
let columns = source.table_schema(&task_table).await?;
Ok(OperationPayload::Schema {
table: task_table,
columns,
})
});
}
fn run_sql(app: &mut AppState, operations: &mut Operations) {
let sql = app.sql.text.trim().to_string();
if sql.is_empty() {
return;
}
if !app.read_only && requires_write_confirmation(&sql) {
app.overlay = Overlay::ConfirmSql { sql };
return;
}
app.sql_mode = SqlMode::Normal;
app.completion = None;
execute_sql_now(app, operations, &sql);
}
fn execute_sql_now(app: &mut AppState, operations: &mut Operations, sql: &str) {
execute_sql_for_table(app, operations, sql, None);
}
fn execute_sql_for_table(
app: &mut AppState,
operations: &mut Operations,
sql: &str,
table: Option<String>,
) {
if is_explicit_transaction_control(sql) {
dispatch(
app,
AppEvent::Error(
"explicit transaction control is unavailable in the TUI; execute one self-contained statement at a time"
.to_string(),
),
);
return;
}
let source = Arc::clone(&operations.source);
let statement = sql.to_string();
let impact = if requires_write_confirmation(&statement) {
OperationImpact::Write
} else {
OperationImpact::Read
};
operations.start_with_impact(app, "Executing SQL", impact, async move {
let outcome = source.execute_sql(&statement, MAX_QUERY_ROWS).await?;
Ok(OperationPayload::Sql {
table,
statement,
outcome,
})
});
}
fn is_explicit_transaction_control(sql: &str) -> bool {
matches!(
first_sql_keyword(sql).as_deref(),
Some("begin" | "savepoint" | "commit" | "end" | "rollback" | "release")
)
}
fn first_sql_keyword(sql: &str) -> Option<String> {
top_level_sql_keywords(sql)?.into_iter().next()
}
fn apply_operation_message(
app: &mut AppState,
operations: &mut Operations,
message: OperationMessage,
) {
if operations.active_id != Some(message.id) {
return;
}
let elapsed = app
.operation
.as_ref()
.filter(|operation| operation.id == message.id)
.map_or(Duration::ZERO, |operation| operation.started.elapsed());
let impact = operations.impact;
app.operation = None;
operations.cancellation = None;
operations.active_id = None;
match message.result {
OperationResult::Canceled => {
operations.record_interrupted_impact(impact);
app.status = operation_interruption_status(impact, false, elapsed);
}
OperationResult::TimedOut => {
operations.record_interrupted_impact(impact);
app.status = operation_interruption_status(impact, true, elapsed);
}
OperationResult::Failed(error) => {
operations.record_failed_impact(impact);
app.status = match impact {
OperationImpact::Write => format!(
"write failed or may have committed: {error}; reconnect or restart and inspect state; writes are disabled for this session"
),
OperationImpact::CommittedWriteRefresh => {
format!("write committed; refresh failed: {error}")
}
OperationImpact::Read => format!("operation failed: {error}"),
};
}
OperationResult::Completed(payload) => {
operations.record_completed_impact(impact);
apply_operation_payload(app, operations, payload, elapsed)
}
}
}
fn operation_interruption_status(
impact: OperationImpact,
timed_out: bool,
elapsed: Duration,
) -> String {
let outcome = if timed_out { "timed out" } else { "canceled" };
match impact {
OperationImpact::Read => {
format!("operation {outcome} after {:.1}s", elapsed.as_secs_f32())
}
OperationImpact::Write => {
format!(
"write {outcome}; outcome is indeterminate — reconnect or restart and inspect state; writes are disabled for this session"
)
}
OperationImpact::CommittedWriteRefresh => {
format!("refresh {outcome}; the write was committed")
}
}
}
fn apply_operation_payload(
app: &mut AppState,
operations: &mut Operations,
payload: OperationPayload,
elapsed: Duration,
) {
match payload {
OperationPayload::Startup(startup) => {
app.tables = startup.tables;
if let Some(catalog) = startup.catalog {
app.schemas.extend(catalog);
}
if let Some((table, page)) = startup.preview {
dispatch(
app,
AppEvent::RowsLoaded {
table: Some(table),
page,
},
);
} else if !app.tables.is_empty() && !operations.source.auto_preview() {
app.status = "Enter or double-click to scan (DynamoDB read capacity is consumed)"
.to_string();
} else {
app.status = format!("database loaded in {:.1}s", elapsed.as_secs_f32());
}
}
OperationPayload::Rows {
table,
page,
schema,
clear_filters,
} => {
app.active_filter = None;
if let Some(schema) = schema {
app.cache_schema(table.clone(), schema);
}
dispatch(
app,
AppEvent::RowsLoaded {
table: Some(table),
page,
},
);
if clear_filters {
reset_filters(app);
}
app.status = format!("table loaded in {:.1}s", elapsed.as_secs_f32());
}
OperationPayload::Filter {
table,
sql,
page,
fallback,
} => {
let (page, truncated) = match page {
Some(value) => value,
None => match fallback {
Some(QueryOutcome::Rows {
columns,
rows,
truncated,
..
}) => (
TablePage {
columns,
rows,
rowids: None,
offset: 0,
has_more: false,
},
truncated,
),
Some(QueryOutcome::Affected(_) | QueryOutcome::Executed) | None => {
app.status = "operation failed: filter did not return rows".to_string();
return;
}
},
};
let row_count = page.rows.len();
dispatch(
app,
AppEvent::RowsLoaded {
table: Some(table.clone()),
page,
},
);
app.active_filter = Some(state::ActiveFilter { table, sql });
let suffix = if truncated { "+ (truncated)" } else { "" };
app.status = format!(
"{row_count}{suffix} filtered rows in {:.1}s",
elapsed.as_secs_f32()
);
}
OperationPayload::Schema { table, columns } => {
dispatch(app, AppEvent::SchemaLoaded { table, columns });
}
OperationPayload::Sql {
table,
statement,
outcome,
} => match outcome {
QueryOutcome::Rows {
columns,
rows,
truncated,
read_only,
..
} => {
let table_filter = table.is_some();
let history_suffix =
record_interactive_read_history(app, &statement, table_filter, read_only);
if !table_filter {
app.active_filter = None;
app.grid.sort = None;
}
let row_count = rows.len();
dispatch(app, AppEvent::FocusPanel(Panel::Grid));
dispatch(
app,
AppEvent::RowsLoaded {
table,
page: TablePage {
columns,
rows,
rowids: None,
offset: 0,
has_more: false,
},
},
);
app.status = format!(
"{}{history_suffix}",
sql_rows_status(row_count, truncated, elapsed)
);
}
QueryOutcome::Affected(rows) => {
app.active_filter = None;
app.status = format!("{rows} rows affected in {:.1}s", elapsed.as_secs_f32());
refresh_after_committed_write(app, operations);
}
QueryOutcome::Executed => {
app.active_filter = None;
app.status = format!("statement executed in {:.1}s", elapsed.as_secs_f32());
refresh_after_committed_write(app, operations);
}
},
OperationPayload::Refresh {
tables,
loaded,
catalog,
} => {
app.tables = tables;
app.selected_table = app.selected_table.min(app.tables.len().saturating_sub(1));
if let Some(catalog) = catalog {
app.schemas.extend(catalog);
}
if let Some((table, page)) = loaded {
dispatch(
app,
AppEvent::RowsLoaded {
table: Some(table),
page,
},
);
} else {
app.loaded_table = None;
app.grid = state::GridState::default();
}
app.status = format!("refreshed in {:.1}s", elapsed.as_secs_f32());
}
OperationPayload::Update {
table,
rowid,
value,
selected_row,
selected_col,
active_filter,
offset,
} => {
if app.loaded_table.as_deref() == Some(table.as_str())
&& app
.grid
.rowids
.as_ref()
.and_then(|rowids| rowids.get(selected_row))
== Some(&rowid)
&& let Some(cell) = app
.grid
.rows
.get_mut(selected_row)
.and_then(|row| row.values.get_mut(selected_col))
{
*cell = value;
}
start_update_refresh(
app,
operations,
table,
active_filter,
offset,
selected_row,
selected_col,
);
}
OperationPayload::UpdateRefresh {
table,
page,
truncated,
selected_row,
selected_col,
active_filter,
} => {
let row_count = page.rows.len();
dispatch(
app,
AppEvent::RowsLoaded {
table: Some(table),
page,
},
);
app.grid.selected_row = selected_row.min(app.grid.rows.len().saturating_sub(1));
app.grid.selected_col = selected_col.min(app.grid.columns.len().saturating_sub(1));
app.active_filter = active_filter;
app.status = format!(
"cell updated; {}",
sql_rows_status(row_count, truncated, elapsed)
);
}
}
}
fn sql_rows_status(row_count: usize, truncated: bool, elapsed: Duration) -> String {
let count = if truncated {
format!("{row_count}+ rows (truncated)")
} else {
format!("{row_count} rows")
};
format!("{count} in {:.1}s", elapsed.as_secs_f32())
}
fn start_update_refresh(
app: &mut AppState,
operations: &mut Operations,
table: String,
active_filter: Option<state::ActiveFilter>,
offset: usize,
selected_row: usize,
selected_col: usize,
) {
let source = Arc::clone(&operations.source);
let limit = app.config.row_limit as i64;
let task_table = table.clone();
operations.start_with_impact(
app,
"Refreshing committed update",
OperationImpact::CommittedWriteRefresh,
async move {
let (page, truncated) = if let Some(filter) = &active_filter {
source
.execute_table_filter(&filter.table, &filter.sql, MAX_QUERY_ROWS)
.await?
.ok_or_else(|| {
crate::db::error::DbError::Unsupported(
"filtered update refresh is unavailable".to_string(),
)
})?
} else {
(
source.fetch_rows(&task_table, limit, offset as i64).await?,
false,
)
};
Ok(OperationPayload::UpdateRefresh {
table: task_table,
page,
truncated,
selected_row,
selected_col,
active_filter,
})
},
);
}
fn record_interactive_read_history(
app: &mut AppState,
sql: &str,
table_filter: bool,
read_only: bool,
) -> String {
if table_filter || !read_only {
return String::new();
}
app.record_successful_read_query(sql)
.err()
.map_or_else(String::new, |error| format!("; history not saved: {error}"))
}
fn requires_write_confirmation(sql: &str) -> bool {
let Some(keywords) = top_level_sql_keywords(sql) else {
return true;
};
!policy_allows_read(&keywords)
}
fn policy_allows_read(keywords: &[String]) -> bool {
match keywords.first().map(String::as_str) {
Some("select" | "values") => true,
Some("explain") => {
let explained = if matches!(
keywords.get(1..3),
Some([query, plan]) if query == "query" && plan == "plan"
) {
&keywords[3..]
} else {
&keywords[1..]
};
policy_allows_read(explained)
}
Some("with") => keywords
.iter()
.skip(1)
.find(|keyword| {
matches!(
keyword.as_str(),
"select" | "values" | "insert" | "update" | "delete" | "replace"
)
})
.is_some_and(|keyword| matches!(keyword.as_str(), "select" | "values")),
_ => false,
}
}
fn top_level_sql_keywords(sql: &str) -> Option<Vec<String>> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Normal,
SingleQuote,
DoubleQuote,
Backtick,
Bracket,
LineComment,
BlockComment,
}
fn finish_token(tokens: &mut Vec<String>, token: &mut String) {
if !token.is_empty() {
tokens.push(std::mem::take(token));
}
}
let mut tokens = Vec::new();
let mut token = String::new();
let mut depth = 0usize;
let mut state = State::Normal;
let mut valid = true;
let mut terminated = false;
let mut chars = sql.chars().peekable();
while let Some(character) = chars.next() {
match state {
State::Normal => {
if character == '-' && chars.peek() == Some(&'-') {
finish_token(&mut tokens, &mut token);
chars.next();
state = State::LineComment;
} else if character == '/' && chars.peek() == Some(&'*') {
finish_token(&mut tokens, &mut token);
chars.next();
state = State::BlockComment;
} else if terminated && !character.is_whitespace() && character != ';' {
valid = false;
} else if character == ';' && depth == 0 {
finish_token(&mut tokens, &mut token);
terminated = true;
} else if character == '(' {
finish_token(&mut tokens, &mut token);
depth = depth.saturating_add(1);
} else if character == ')' {
finish_token(&mut tokens, &mut token);
if depth == 0 {
valid = false;
} else {
depth -= 1;
}
} else if matches!(character, '\'' | '"' | '`' | '[') {
finish_token(&mut tokens, &mut token);
state = match character {
'\'' => State::SingleQuote,
'"' => State::DoubleQuote,
'`' => State::Backtick,
'[' => State::Bracket,
_ => State::Normal,
};
} else if depth == 0 && (character.is_ascii_alphanumeric() || character == '_') {
token.push(character.to_ascii_lowercase());
} else {
finish_token(&mut tokens, &mut token);
}
}
State::SingleQuote if character == '\'' => {
if chars.peek() == Some(&'\'') {
chars.next();
} else {
state = State::Normal;
}
}
State::DoubleQuote if character == '"' => {
if chars.peek() == Some(&'"') {
chars.next();
} else {
state = State::Normal;
}
}
State::Backtick if character == '`' => state = State::Normal,
State::Bracket if character == ']' => state = State::Normal,
State::LineComment if character == '\n' => state = State::Normal,
State::BlockComment if character == '*' && chars.peek() == Some(&'/') => {
chars.next();
state = State::Normal;
}
_ => {}
}
}
finish_token(&mut tokens, &mut token);
(valid && depth == 0 && matches!(state, State::Normal | State::LineComment)).then_some(tokens)
}
fn yank_cell(app: &mut AppState) {
let text = match app.focus {
Panel::Tables => app.tables.get(app.selected_table).cloned(),
Panel::Grid if app.data_tab == state::DataTab::Rows => app.visual_yank_text(),
Panel::Grid => None,
Panel::Sql | Panel::Inspector => None,
};
let Some(text) = text else { return };
let result = arboard::Clipboard::new().and_then(|mut c| c.set_text(text.clone()));
match result {
Ok(()) => {
app.grid.visual = Visual::None;
let lines = text.lines().count();
dispatch(app, AppEvent::Status(format!("yanked {lines} line(s)")));
}
Err(err) => dispatch(app, AppEvent::Error(format!("clipboard: {err}"))),
}
}
fn open_advanced_copy(app: &mut AppState) {
if app.grid.rows.is_empty() {
dispatch(app, AppEvent::Status("nothing to copy".to_string()));
return;
}
app.overlay = Overlay::Copy {
format: CopyFormat::Csv,
headers: true,
};
}
fn copy_advanced(app: &mut AppState, format: CopyFormat, headers: bool) {
let Some((text, row_count)) = format_advanced_copy(app, format, headers) else {
dispatch(app, AppEvent::Error("nothing to copy".to_string()));
return;
};
let result = arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text));
match result {
Ok(()) => {
app.grid.visual = Visual::None;
dispatch(app, AppEvent::CloseOverlay);
dispatch(
app,
AppEvent::Status(format!("copied {row_count} row(s) as {}", format.label())),
);
}
Err(error) => dispatch(app, AppEvent::Error(format!("clipboard: {error}"))),
}
}
fn format_advanced_copy(
app: &AppState,
format: CopyFormat,
headers: bool,
) -> Option<(String, usize)> {
let column_indexes: Vec<usize> = match app.grid.visual {
Visual::Column => vec![app.grid.selected_col],
Visual::None | Visual::Rows { .. } => (0..app.grid.columns.len()).collect(),
};
let row_indexes: Vec<usize> = match app.grid.visual {
Visual::Rows { anchor } => {
let start = anchor.min(app.grid.selected_row);
let end = anchor.max(app.grid.selected_row);
(start..=end).collect()
}
Visual::Column => (0..app.grid.rows.len()).collect(),
Visual::None => vec![app.grid.selected_row],
};
if column_indexes.is_empty() || row_indexes.is_empty() {
return None;
}
let columns: Vec<&str> = column_indexes
.iter()
.filter_map(|index| {
app.grid
.columns
.get(*index)
.map(|column| column.name.as_str())
})
.collect();
let rows: Vec<Vec<&Value>> = row_indexes
.iter()
.filter_map(|row_index| app.grid.rows.get(*row_index))
.map(|row| {
column_indexes
.iter()
.filter_map(|column_index| row.values.get(*column_index))
.collect()
})
.collect();
if columns.is_empty() || rows.is_empty() {
return None;
}
let text = match format {
CopyFormat::Csv => format_delimited(&columns, &rows, ',', headers),
CopyFormat::Tsv => format_delimited(&columns, &rows, '\t', headers),
CopyFormat::Json => format_json(&columns, &rows, headers)?,
};
Some((text, rows.len()))
}
fn format_delimited(
columns: &[&str],
rows: &[Vec<&Value>],
delimiter: char,
headers: bool,
) -> String {
let separator = delimiter.to_string();
let mut lines = Vec::with_capacity(rows.len() + usize::from(headers));
if headers {
lines.push(
columns
.iter()
.map(|column| escape_delimited(column, delimiter))
.collect::<Vec<_>>()
.join(&separator),
);
}
lines.extend(rows.iter().map(|row| {
row.iter()
.map(|value| escape_delimited(©_value_text(value), delimiter))
.collect::<Vec<_>>()
.join(&separator)
}));
lines.join("\n")
}
fn escape_delimited(value: &str, delimiter: char) -> String {
if value.contains(delimiter)
|| value.contains('"')
|| value.contains('\n')
|| value.contains('\r')
{
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn copy_value_text(value: &Value) -> String {
match value {
Value::Bytes(bytes) => format!(
"0x{}",
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
),
value => value.to_string(),
}
}
fn format_json(columns: &[&str], rows: &[Vec<&Value>], headers: bool) -> Option<String> {
let value = if headers {
let columns = unique_json_columns(columns);
serde_json::Value::Array(
rows.iter()
.map(|row| {
let object = columns
.iter()
.zip(row)
.map(|(column, value)| (column.clone(), value.to_json()))
.collect();
serde_json::Value::Object(object)
})
.collect(),
)
} else {
serde_json::Value::Array(
rows.iter()
.map(|row| {
serde_json::Value::Array(row.iter().map(|value| value.to_json()).collect())
})
.collect(),
)
};
serde_json::to_string_pretty(&value).ok()
}
fn unique_json_columns(columns: &[&str]) -> Vec<String> {
let mut names = Vec::with_capacity(columns.len());
for column in columns {
let mut name = (*column).to_string();
let mut suffix = 2;
while names
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(&name))
{
name = format!("{column}_{suffix}");
suffix += 1;
}
names.push(name);
}
names
}
fn commit_value(app: &mut AppState, operations: &mut Operations) {
let Overlay::Edit { text, target, cell } = &app.overlay else {
return;
};
let (text, target, cell) = (text.clone(), *target, cell.clone());
if target != EditTarget::Cell {
if !app.config_writable {
dispatch(
app,
AppEvent::Error("configuration is invalid; repair it before saving".to_string()),
);
return;
}
let previous = app.config.clone();
let result = match target {
EditTarget::RowLimit => text
.parse::<usize>()
.map(|value| app.config.row_limit = value)
.map_err(|_| "row_limit must be an integer".to_string()),
EditTarget::ColWidth => text
.parse::<u16>()
.map(|value| app.config.col_width = value)
.map_err(|_| "col_width must be an integer".to_string()),
EditTarget::ForegroundTimeout => text
.parse::<u64>()
.map(|value| app.config.foreground_timeout_ms = value)
.map_err(|_| "foreground_timeout_ms must be an integer".to_string()),
EditTarget::Cell => Ok(()),
};
if let Err(error) = result {
dispatch(app, AppEvent::Error(error));
return;
}
app.config.normalize();
if let Err(error) = app.config.save() {
app.config = previous;
dispatch(app, AppEvent::Error(format!("config: {error}")));
return;
}
dispatch(app, AppEvent::CloseOverlay);
if target == EditTarget::RowLimit {
refresh(app, operations);
}
dispatch(app, AppEvent::Status("settings saved".to_string()));
return;
}
let Some(CellEditIdentity {
table,
rowid,
column,
selected_row,
selected_col,
original,
}) = cell
else {
dispatch(app, AppEvent::CloseOverlay);
dispatch(
app,
AppEvent::Error("edit canceled because its cell identity was lost".to_string()),
);
return;
};
let value = match original.edited(&text) {
Ok(value) => value,
Err(msg) => {
dispatch(app, AppEvent::Error(msg));
return;
}
};
dispatch(app, AppEvent::CloseOverlay);
let offset = if app.loaded_table.as_deref() == Some(table.as_str()) {
app.grid.page_offset
} else {
0
};
let active_filter = app
.active_filter
.clone()
.filter(|filter| filter.table == table);
let source = Arc::clone(&operations.source);
let task_table = table.clone();
let committed_value = value.clone();
operations.start_with_impact(
app,
format!("Updating {table}.{column}"),
OperationImpact::Write,
async move {
source
.update_cell(&task_table, rowid, &column, &value)
.await?;
Ok(OperationPayload::Update {
table: task_table,
rowid,
value: committed_value,
selected_row,
selected_col,
active_filter,
offset,
})
},
);
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use crate::db::model::{Column, Row, TablePage};
#[test]
fn mouse_state_recognizes_one_double_click() {
let mut mouse = MouseState::default();
assert!(!mouse.register_click(Panel::Tables, 2, 3));
assert!(mouse.register_click(Panel::Tables, 2, 3));
assert!(!mouse.register_click(Panel::Tables, 2, 3));
}
#[test]
fn bracket_navigation_switches_tabs_without_changing_panes() {
let mut app = copy_app();
app.focus = Panel::Sql;
switch_tab(&mut app, false);
assert_eq!(app.focus, Panel::Sql);
assert_eq!(app.editor_tab, EditorTab::Sql);
app.focus = Panel::Grid;
switch_tab(&mut app, false);
assert_eq!(app.focus, Panel::Grid);
assert_eq!(app.data_tab, state::DataTab::Schema);
app.focus = Panel::Inspector;
switch_tab(&mut app, true);
assert_eq!(app.record_tab, state::RecordTab::Json);
}
#[test]
fn bracketed_paste_inserts_reserved_tab_keys_into_filter_values() {
let mut app = copy_app();
app.focus = Panel::Sql;
app.filters.select_field(FilterField::Value);
handle_paste(&mut app, "array[0]".to_string());
assert_eq!(app.filters.selected_row().value, "array[0]");
}
#[tokio::test]
async fn unmodified_data_rows_o_takes_priority_only_in_its_reachable_context() {
let database = tempfile::NamedTempFile::new().unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(database.path())
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
app.focus = Panel::Grid;
app.grid.selected_col = 1;
handle_key(
&mut app,
&mut operations,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
);
assert_eq!(app.grid.sort, None);
app.data_tab = state::DataTab::Schema;
handle_key(
&mut app,
&mut operations,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert_eq!(app.grid.sort, None);
app.data_tab = state::DataTab::Rows;
app.config.shortcuts.quit = "o".to_string();
handle_key(
&mut app,
&mut operations,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert_eq!(
app.grid
.sort
.as_ref()
.map(|sort| (sort.column, sort.direction)),
Some((1, state::SortDirection::Ascending))
);
assert!(app.status.contains("current result/page"));
assert!(!app.should_quit);
app.focus = Panel::Tables;
handle_key(
&mut app,
&mut operations,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert!(app.should_quit);
}
#[test]
fn ctrl_r_is_sql_tab_only_and_escape_preserves_completion_state() {
let mut app = copy_app();
app.focus = Panel::Sql;
app.editor_tab = EditorTab::Sql;
app.sql_mode = SqlMode::Insert;
app.sql.insert_str("sel");
app.refresh_completion(false);
app.query_history.record("SELECT * FROM people");
let completion = app.completion.clone();
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(handle_history_key(&mut app, ctrl_r));
assert!(matches!(app.overlay, Overlay::History(_)));
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
));
assert_eq!(app.overlay, Overlay::None);
assert_eq!(app.completion, completion);
app.editor_tab = EditorTab::Filters;
assert!(!handle_history_key(&mut app, ctrl_r));
assert_eq!(app.editor_tab, EditorTab::Filters);
app.editor_tab = EditorTab::Sql;
app.overlay = Overlay::Help;
assert!(handle_history_key(&mut app, ctrl_r));
assert_eq!(app.overlay, Overlay::Help);
app.overlay = Overlay::ConfirmSql {
sql: "DELETE FROM people".to_string(),
};
assert!(handle_history_key(&mut app, ctrl_r));
assert_eq!(
app.overlay,
Overlay::ConfirmSql {
sql: "DELETE FROM people".to_string()
}
);
}
#[test]
fn history_keys_search_navigate_and_load_without_running() {
let mut app = copy_app();
app.focus = Panel::Sql;
app.editor_tab = EditorTab::Sql;
app.query_history.record("SELECT 1");
app.query_history.record("SELECT 2");
app.status = "idle".to_string();
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)
));
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)
));
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
));
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)
));
assert!(handle_history_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
));
assert_eq!(app.overlay, Overlay::None);
assert_eq!(app.sql.text, "SELECT 2");
assert_eq!(app.status, "idle");
}
#[test]
fn editor_title_and_filter_controls_have_mouse_targets() {
let mut app = copy_app();
let area = Rect::new(10, 5, 100, 6);
assert!(select_editor_tab_at(&mut app, area, 25, 5));
assert_eq!(app.editor_tab, EditorTab::Sql);
assert!(select_editor_tab_at(&mut app, area, 15, 5));
assert_eq!(app.editor_tab, EditorTab::Filters);
assert_eq!(
filter_hit_target(&app, area, 12, 6),
Some((0, FilterField::Enabled))
);
assert_eq!(filter_hit_target(&app, area, 10, 6), None);
for _ in 0..4 {
app.filters.add_row();
}
assert_eq!(
filter_hit_target(&app, area, 12, 6),
Some((2, FilterField::Enabled))
);
assert_eq!(filter_hit_target(&app, area, 12, 9), None);
}
#[test]
fn hiding_tables_focuses_a_visible_pane_when_editor_is_hidden() {
let mut app = copy_app();
app.focus = Panel::Tables;
app.config.editor_visible = false;
toggle_sidebar(&mut app);
assert_eq!(app.focus, Panel::Grid);
}
#[test]
fn conservative_write_policy_allows_reads_and_confirms_everything_else() {
assert!(!requires_write_confirmation("SELECT 1"));
assert!(!requires_write_confirmation(
"-- comment\nWITH one(n) AS (VALUES (1)) SELECT n FROM one"
));
assert!(!requires_write_confirmation("EXPLAIN SELECT 1"));
assert!(!requires_write_confirmation("EXPLAIN QUERY PLAN SELECT 1"));
assert!(!requires_write_confirmation(
"EXPLAIN WITH one(n) AS (VALUES (1)) SELECT n FROM one"
));
assert!(!requires_write_confirmation(
"EXPLAIN QUERY PLAN WITH one(n) AS (VALUES (1)) SELECT n FROM one"
));
assert!(requires_write_confirmation(
"EXPLAIN PRAGMA user_version = 9"
));
assert!(requires_write_confirmation(
"EXPLAIN QUERY PLAN PRAGMA user_version = 9"
));
assert!(requires_write_confirmation(
"WITH one(n) AS (VALUES (1)) DELETE FROM jobs"
));
assert!(requires_write_confirmation(
"ATTACH DATABASE 'private.sqlite' AS private"
));
assert!(requires_write_confirmation("PRAGMA journal_mode = WAL"));
assert!(requires_write_confirmation("PRAGMA optimize"));
assert!(requires_write_confirmation("CREATE TABLE private (id INT)"));
assert!(requires_write_confirmation("VACUUM"));
assert!(requires_write_confirmation("unclassified command"));
assert!(requires_write_confirmation("SELECT 'unterminated"));
assert!(requires_write_confirmation("SELECT 1; DELETE FROM jobs"));
}
#[tokio::test]
async fn confirmation_policy_has_no_attach_or_pragma_side_effects_before_confirmation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("confirmation.sqlite");
std::fs::File::create(&path).unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(Arc::clone(&source));
let mut app = AppState::new_with_access(vec![], false);
let attach = "ATTACH DATABASE ':memory:' AS private_db";
app.sql.text = attach.to_string();
run_sql(&mut app, &mut operations);
assert_eq!(app.overlay, Overlay::ConfirmSql { sql: attach.into() });
let databases = source
.execute_sql("PRAGMA database_list", 100)
.await
.unwrap();
let QueryOutcome::Rows { rows, .. } = databases else {
panic!("expected database rows");
};
assert!(
rows.iter()
.all(|row| { !row.values.contains(&Value::Text("private_db".to_string())) })
);
app.overlay = Overlay::None;
app.sql.text = "PRAGMA user_version = 9".to_string();
run_sql(&mut app, &mut operations);
assert_eq!(
app.overlay,
Overlay::ConfirmSql {
sql: "PRAGMA user_version = 9".to_string()
}
);
for statement in [
"EXPLAIN PRAGMA user_version = 10",
"EXPLAIN QUERY PLAN PRAGMA user_version = 11",
] {
app.overlay = Overlay::None;
app.sql.text = statement.to_string();
run_sql(&mut app, &mut operations);
assert_eq!(
app.overlay,
Overlay::ConfirmSql {
sql: statement.to_string()
}
);
}
let version = source.execute_sql("PRAGMA user_version", 1).await.unwrap();
let QueryOutcome::Rows { rows, .. } = version else {
panic!("expected pragma rows");
};
assert_eq!(
rows.first().and_then(|row| row.values.first()),
Some(&Value::Int(0))
);
}
#[test]
fn invalid_config_disables_history_for_the_session() {
let mut app = copy_app();
apply_config(&mut app, Err(anyhow::anyhow!("invalid config")));
assert!(!app.config_writable);
assert!(!app.config.query_history_enabled);
assert!(!app.history_recording_enabled());
app.record_successful_read_query("SELECT 'private'")
.unwrap();
assert!(app.query_history.entries().is_empty());
}
#[test]
fn failed_history_opt_out_stays_disabled_for_the_session() {
let mut app = copy_app();
assert!(app.config.query_history_enabled);
assert!(app.history_recording_enabled());
toggle_query_history_with(&mut app, |_| Err(anyhow::anyhow!("disk full")));
assert!(app.config.query_history_enabled);
assert!(!app.history_recording_enabled());
assert!(app.status.contains("disk full"));
app.record_successful_read_query("SELECT 'private'")
.unwrap();
assert!(app.query_history.entries().is_empty());
}
#[test]
fn explicit_transaction_workflows_are_rejected() {
for sql in [
"BEGIN",
"SAVEPOINT edit",
"COMMIT",
"END TRANSACTION",
"ROLLBACK",
"RELEASE edit",
"-- comment\nBEGIN IMMEDIATE",
] {
assert!(is_explicit_transaction_control(sql), "{sql}");
}
assert!(!is_explicit_transaction_control(
"UPDATE authors SET name = 'Ada'"
));
assert!(!is_explicit_transaction_control("SELECT 1"));
}
#[test]
fn truncated_sql_results_are_explicitly_bounded() {
assert_eq!(
sql_rows_status(1_000, true, Duration::from_millis(250)),
"1000+ rows (truncated) in 0.2s"
);
assert_eq!(
sql_rows_status(42, false, Duration::from_secs(1)),
"42 rows in 1.0s"
);
}
#[tokio::test]
async fn unrelated_ad_hoc_sql_results_reset_local_sort_state() {
let database = tempfile::NamedTempFile::new().unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(database.path())
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = AppState::new(vec![]);
execute_sql_for_table(
&mut app,
&mut operations,
"SELECT 2 AS value UNION ALL SELECT 1",
None,
);
finish_operation(&mut app, &mut operations).await;
dispatch(&mut app, AppEvent::CycleSort);
assert!(app.loaded_table.is_none());
assert!(app.grid.sort.is_some());
execute_sql_for_table(
&mut app,
&mut operations,
"SELECT 4 AS value UNION ALL SELECT 3",
None,
);
finish_operation(&mut app, &mut operations).await;
assert!(app.loaded_table.is_none());
assert_eq!(app.grid.sort, None);
assert_eq!(app.grid.rows[0].values, vec![Value::Int(4)]);
assert_eq!(app.grid.rows[1].values, vec![Value::Int(3)]);
}
#[test]
fn advanced_copy_formats_csv_and_escapes_fields() {
let app = copy_app();
let (text, rows) = format_advanced_copy(&app, CopyFormat::Csv, true).unwrap();
assert_eq!(rows, 1);
assert_eq!(text, "name,note\nAda,\"one,two\"");
}
#[test]
fn advanced_copy_formats_selected_rows_as_json_objects() {
let mut app = copy_app();
app.grid.visual = Visual::Rows { anchor: 0 };
app.grid.selected_row = 1;
let (text, rows) = format_advanced_copy(&app, CopyFormat::Json, true).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(rows, 2);
assert_eq!(value[0]["name"], "Ada");
assert_eq!(value[1]["note"], "compiler");
}
#[test]
fn advanced_copy_can_omit_headers() {
let app = copy_app();
let (tsv, _) = format_advanced_copy(&app, CopyFormat::Tsv, false).unwrap();
let (json, _) = format_advanced_copy(&app, CopyFormat::Json, false).unwrap();
assert_eq!(tsv, "Ada\tone,two");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap()[0][0],
"Ada"
);
}
#[test]
fn advanced_json_copy_disambiguates_duplicate_columns() {
let first = Value::Int(1);
let second = Value::Int(2);
let text = format_json(&["id", "id"], &[vec![&first, &second]], true).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value[0]["id"], 1);
assert_eq!(value[0]["id_2"], 2);
}
#[test]
fn filter_builder_quotes_values_and_identifiers() {
let mut app = copy_app();
app.filters.selected_row_mut().enabled = true;
app.filters.selected_row_mut().value = "O'Brien".to_string();
let (_, sql) = build_filters_sql(&app).unwrap();
assert_eq!(sql, "SELECT * FROM \"people\" WHERE \"name\" = 'O''Brien'");
app.filters.selected_row_mut().value = "true".to_string();
let (_, boolean_text) = build_filters_sql(&app).unwrap();
assert!(boolean_text.ends_with("\"name\" = 'true'"));
}
#[test]
fn filter_builder_uses_backend_specific_contains() {
let mut app = copy_app();
app.filters.selected_row_mut().enabled = true;
app.filters
.selected_row_mut()
.set_column(1, "note".to_string());
app.filters.selected_row_mut().operator = FilterOperator::Contains;
app.filters.selected_row_mut().value = "10%_off".to_string();
let (_, sqlite) = build_filters_sql(&app).unwrap();
assert!(sqlite.contains("LIKE '%10\\%\\_off%' ESCAPE '\\'"));
app.backend_name = "DynamoDB";
app.filters.selected_row_mut().value = "active".to_string();
let (_, partiql) = build_filters_sql(&app).unwrap();
assert_eq!(
partiql,
"SELECT * FROM \"people\" WHERE contains(\"note\", 'active')"
);
app.filters.selected_row_mut().operator = FilterOperator::In;
app.filters.selected_row_mut().value = "active, archived".to_string();
let (_, partiql) = build_filters_sql(&app).unwrap();
assert_eq!(
partiql,
"SELECT * FROM \"people\" WHERE \"note\" IN ['active', 'archived']"
);
}
#[test]
fn filter_builder_combines_enabled_rows() {
let mut app = copy_app();
app.filters.selected_row_mut().enabled = true;
app.filters.selected_row_mut().value = "Ada".to_string();
app.filters.add_row();
app.filters.selected_row_mut().enabled = true;
app.filters
.selected_row_mut()
.set_column(1, "note".to_string());
app.filters.selected_row_mut().operator = FilterOperator::IsNotNull;
let (_, sql) = build_filters_sql(&app).unwrap();
assert_eq!(
sql,
"SELECT * FROM \"people\" WHERE \"name\" = 'Ada' AND \"note\" IS NOT NULL"
);
}
#[test]
fn dynamodb_filter_keeps_numeric_type_across_sparse_pages() {
let mut app = AppState::new(vec!["people".to_string()]);
app.backend_name = "DynamoDB";
dispatch(
&mut app,
AppEvent::RowsLoaded {
table: Some("people".to_string()),
page: TablePage {
columns: vec![Column {
name: "score".to_string(),
}],
rows: vec![Row {
values: vec![Value::Int(42)],
}],
rowids: None,
offset: 0,
has_more: true,
},
},
);
app.filters.selected_row_mut().value = "42".to_string();
dispatch(
&mut app,
AppEvent::RowsLoaded {
table: Some("people".to_string()),
page: TablePage {
columns: vec![Column {
name: "status".to_string(),
}],
rows: vec![Row {
values: vec![Value::Text("active".to_string())],
}],
rowids: None,
offset: 1,
has_more: false,
},
},
);
let (_, sql) = build_filters_sql(&app).unwrap();
assert_eq!(sql, "SELECT * FROM \"people\" WHERE \"score\" = 42");
}
#[tokio::test]
async fn editing_a_filtered_sqlite_row_reapplies_the_filter() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let tables = source.list_tables().await.unwrap();
let mut app = AppState::new_with_access(tables, false);
let mut operations = Operations::new(source);
load_selected_table(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
app.filters.selected_row_mut().enabled = true;
app.filters
.selected_row_mut()
.set_column(2, "country".to_string());
app.filters.selected_row_mut().value = "Poland".to_string();
apply_filters(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
assert!(app.active_filter.is_some());
assert_eq!(app.grid.rows.len(), 1);
execute_sql_for_table(
&mut app,
&mut operations,
"SELECT missing FROM nowhere",
None,
);
finish_operation(&mut app, &mut operations).await;
assert!(app.active_filter.is_some());
assert_eq!(app.grid.rows.len(), 1);
app.grid.selected_col = 1;
set_cell_edit_text(&mut app, "Edited");
commit_value(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
finish_operation(&mut app, &mut operations).await;
assert!(app.active_filter.is_some());
assert_eq!(app.grid.rows.len(), 1);
assert_eq!(
app.grid.rows[0].values[1],
Value::Text("Edited".to_string())
);
assert!(app.status.contains("cell updated"));
}
#[tokio::test]
async fn only_enabled_successful_interactive_read_queries_are_persisted() {
let dir = tempfile::tempdir().unwrap();
let database_path = dir.path().join("demo.sqlite");
crate::db::sqlite::ensure_demo_db(&database_path)
.await
.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&database_path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let store = QueryHistoryStore::at(dir.path().join("data/query-history.json"));
let loaded = store.load().unwrap();
let mut app = AppState::new(vec![]);
app.set_query_history_store(store.clone(), loaded.history);
let with_read = "WITH one(n) AS (VALUES (1)) SELECT n FROM one";
let read_pragma = "PRAGMA table_info(authors)";
execute_and_finish(&mut app, &mut operations, "SELECT 1", None).await;
execute_and_finish(&mut app, &mut operations, with_read, None).await;
execute_and_finish(&mut app, &mut operations, read_pragma, None).await;
execute_and_finish(
&mut app,
&mut operations,
"SELECT missing FROM nowhere",
None,
)
.await;
execute_and_finish(
&mut app,
&mut operations,
"SELECT * FROM authors",
Some("authors".to_string()),
)
.await;
for statement in [
"UPDATE authors SET name = name RETURNING id",
"PRAGMA user_version = 7",
"PRAGMA journal_mode = WAL",
] {
assert!(record_interactive_read_history(&mut app, statement, false, false).is_empty());
}
app.config.query_history_enabled = false;
execute_and_finish(&mut app, &mut operations, "SELECT 2", None).await;
execute_and_finish(&mut app, &mut operations, "SELECT 1", None).await;
assert_eq!(
store.load().unwrap().history.entries(),
&["SELECT 1", with_read, read_pragma]
);
}
#[tokio::test]
async fn failed_filter_clear_keeps_the_filter_and_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
let original_rows = app.grid.rows.len();
app.loaded_table = Some("missing".to_string());
app.filters.selected_row_mut().value = "Ada".to_string();
app.active_filter = Some(state::ActiveFilter {
table: "missing".to_string(),
sql: "SELECT * FROM missing".to_string(),
});
clear_filters(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
assert_eq!(app.filters.selected_row().value, "Ada");
assert!(app.active_filter.is_some());
assert_eq!(app.grid.rows.len(), original_rows);
assert_ne!(app.status, "filters cleared");
}
#[tokio::test]
async fn stale_completion_cannot_replace_current_operation_or_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
let original_cell = app.selected_cell_text();
let (old_context, _old_cancellation) = OperationContext::new(Duration::from_secs(1));
let (current_context, _current_cancellation) =
OperationContext::new(Duration::from_secs(1));
app.operation = Some(state::ForegroundOperation {
id: current_context.id(),
label: "current".to_string(),
started: Instant::now(),
});
operations.active_id = Some(current_context.id());
apply_operation_message(
&mut app,
&mut operations,
OperationMessage {
id: old_context.id(),
result: OperationResult::Failed("stale".to_string()),
},
);
assert_eq!(
app.operation.as_ref().map(|operation| operation.id),
Some(current_context.id())
);
assert_eq!(app.selected_cell_text(), original_cell);
assert!(!app.status.contains("stale"));
}
#[test]
fn write_interruptions_and_post_commit_refreshes_report_accurate_outcomes() {
let elapsed = Duration::from_secs(2);
assert!(
operation_interruption_status(OperationImpact::Write, false, elapsed)
.contains("writes are disabled for this session")
);
assert!(
operation_interruption_status(OperationImpact::Write, true, elapsed)
.contains("writes are disabled for this session")
);
assert!(
operation_interruption_status(OperationImpact::CommittedWriteRefresh, false, elapsed)
.contains("write was committed")
);
}
#[tokio::test]
async fn completed_sql_write_clears_filter_and_blocks_queued_second_submit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("write.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
app.active_filter = Some(state::ActiveFilter {
table: "people".to_string(),
sql: "SELECT * FROM people".to_string(),
});
let (_, cancellation) = OperationContext::new(Duration::from_secs(1));
operations.cancellation = Some(cancellation);
operations.impact = OperationImpact::Write;
app.operation = Some(state::ForegroundOperation {
id: operations.cancellation.as_ref().unwrap().id(),
label: "write".to_string(),
started: Instant::now(),
});
let id = app.operation.as_ref().unwrap().id;
operations.active_id = Some(id);
apply_operation_message(
&mut app,
&mut operations,
OperationMessage {
id,
result: OperationResult::Completed(OperationPayload::Sql {
table: None,
statement: "UPDATE authors SET name = name".to_string(),
outcome: QueryOutcome::Affected(1),
}),
},
);
assert!(app.active_filter.is_none());
assert!(matches!(
operations.impact,
OperationImpact::CommittedWriteRefresh
));
let refresh_id = operations.active_id;
execute_sql_for_table(
&mut app,
&mut operations,
"UPDATE authors SET country = 'queued duplicate'",
None,
);
assert_eq!(operations.active_id, refresh_id);
assert_eq!(operations.write_safety, WriteSafety::CommittedRefresh);
assert!(app.status.contains("write committed"));
operations.cancel_active();
finish_operation(&mut app, &mut operations).await;
assert!(app.status.contains("write was committed"));
}
#[tokio::test]
async fn double_submit_does_not_cancel_or_replace_an_active_write() {
let (mut app, mut operations) = app_with_pending_write().await;
let write_id = operations.active_id;
execute_sql_for_table(
&mut app,
&mut operations,
"UPDATE authors SET country = 'PL'",
None,
);
assert_eq!(operations.active_id, write_id);
assert!(matches!(operations.impact, OperationImpact::Write));
assert!(app.status.contains("write operation is still in progress"));
operations.cancel_active();
finish_operation(&mut app, &mut operations).await;
}
#[tokio::test]
async fn reads_and_refreshes_never_unlock_an_indeterminate_write() {
let (mut app, mut operations) = app_with_pending_write().await;
operations.cancel_active();
finish_operation(&mut app, &mut operations).await;
operations.after_draw(&mut app);
refresh(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
assert_eq!(
operations.write_safety,
WriteSafety::Indeterminate { was_drawn: true }
);
execute_sql_for_table(
&mut app,
&mut operations,
"UPDATE authors SET country = 'duplicate'",
None,
);
assert!(operations.active_id.is_none());
assert_eq!(
operations.write_safety,
WriteSafety::Indeterminate { was_drawn: true }
);
assert!(app.status.contains("writes are disabled for this session"));
}
#[tokio::test]
async fn navigation_does_not_cancel_or_replace_an_active_write() {
let (mut app, mut operations) = app_with_pending_write().await;
let write_id = operations.active_id;
let table = app.tables[0].clone();
load_table_page(&mut app, &mut operations, table, 0);
assert_eq!(operations.active_id, write_id);
assert!(matches!(operations.impact, OperationImpact::Write));
assert!(app.status.contains("write operation is still in progress"));
operations.cancel_active();
finish_operation(&mut app, &mut operations).await;
}
#[tokio::test]
async fn edit_commit_uses_identity_captured_before_async_rows_arrive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("edit-identity.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let tables = source.list_tables().await.unwrap();
let mut app = AppState::new_with_access(tables, false);
let mut operations = Operations::new(Arc::clone(&source));
load_table_page(&mut app, &mut operations, "authors".to_string(), 0);
finish_operation(&mut app, &mut operations).await;
app.grid.selected_row = 0;
app.grid.selected_col = 2;
set_cell_edit_text(&mut app, "Captured");
dispatch(
&mut app,
AppEvent::RowsLoaded {
table: Some("books".to_string()),
page: TablePage {
columns: vec![Column {
name: "different".to_string(),
}],
rows: vec![Row {
values: vec![Value::Text("do not edit".to_string())],
}],
rowids: Some(vec![999]),
offset: 0,
has_more: false,
},
},
);
commit_value(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
finish_operation(&mut app, &mut operations).await;
let authors = source.fetch_rows("authors", 10, 0).await.unwrap();
assert_eq!(
authors.rows[0].values[2],
Value::Text("Captured".to_string())
);
}
#[tokio::test]
async fn interactive_transaction_control_never_starts_an_operation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("transaction.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
execute_sql_for_table(&mut app, &mut operations, "BEGIN IMMEDIATE", None);
assert!(operations.active_id.is_none());
assert!(app.status.contains("explicit transaction control"));
}
#[tokio::test]
async fn quit_during_write_reports_indeterminate_outcome() {
let (mut app, mut operations) = app_with_pending_write().await;
request_quit(&mut app, &mut operations);
assert!(!app.should_quit);
assert!(operations.quit_after_draw);
assert!(app.status.contains("writes are disabled for this session"));
assert_eq!(
app.operation
.as_ref()
.map(|operation| operation.label.as_str()),
Some(app.status.as_str())
);
operations.after_draw(&mut app);
assert!(app.should_quit);
let warning = operations.take_cleanup_warning().unwrap();
assert!(warning.contains("reconnect or restart"));
assert!(warning.contains("inspect database state"));
}
#[tokio::test]
async fn abnormal_exit_extracts_write_warning_and_preserves_original_error() {
let (_app, mut operations) = app_with_pending_write().await;
let warning = operations.take_cleanup_warning().unwrap();
let result = combine_run_results(
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"event loop failed",
)),
Err(io::Error::other("cleanup also failed")),
);
assert!(warning.contains("event loop exited"));
let error = result.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
assert_eq!(error.to_string(), "event loop failed");
}
#[tokio::test]
async fn active_write_completion_is_applied_without_a_display_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("write-completion.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
let (context, cancellation) = OperationContext::new(Duration::from_secs(1));
operations.active_id = Some(context.id());
operations.cancellation = Some(cancellation);
operations.impact = OperationImpact::Write;
app.operation = None;
apply_operation_message(
&mut app,
&mut operations,
OperationMessage {
id: context.id(),
result: OperationResult::Failed("remote rejected write".to_string()),
},
);
assert!(app.status.contains("remote rejected write"));
assert!(operations.active_id.is_none());
}
#[tokio::test]
async fn failed_refresh_after_sql_write_reports_that_write_committed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("committed-refresh.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let mut operations = Operations::new(source);
let mut app = copy_app();
let (context, cancellation) = OperationContext::new(Duration::from_secs(1));
operations.active_id = Some(context.id());
operations.cancellation = Some(cancellation);
operations.impact = OperationImpact::CommittedWriteRefresh;
app.operation = Some(state::ForegroundOperation {
id: context.id(),
label: "refresh".to_string(),
started: Instant::now(),
});
apply_operation_message(
&mut app,
&mut operations,
OperationMessage {
id: context.id(),
result: OperationResult::Failed("offline".to_string()),
},
);
assert!(app.status.contains("write committed; refresh failed"));
assert!(app.status.contains("offline"));
}
#[tokio::test]
async fn inline_update_refresh_preserves_selected_row_and_column() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("selection.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let tables = source.list_tables().await.unwrap();
let mut app = AppState::new_with_access(tables, false);
let mut operations = Operations::new(source);
load_selected_table(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
app.grid.selected_row = 2;
app.grid.selected_col = 1;
set_cell_edit_text(&mut app, "Selected");
commit_value(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
finish_operation(&mut app, &mut operations).await;
assert_eq!(app.grid.selected_row, 2);
assert_eq!(app.grid.selected_col, 1);
assert_eq!(app.selected_cell_text().as_deref(), Some("Selected"));
}
#[tokio::test]
async fn filtered_inline_update_refresh_preserves_truncation_status() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("filtered-update.sqlite");
std::fs::File::create(&path).unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
source
.execute_sql("CREATE TABLE many (id INTEGER PRIMARY KEY, value TEXT)", 1)
.await
.unwrap();
source
.execute_sql(
"WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1001) INSERT INTO many SELECT value, 'original' FROM numbers",
1,
)
.await
.unwrap();
let mut app = AppState::new_with_access(vec!["many".to_string()], false);
let mut operations = Operations::new(source);
load_selected_table(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
app.grid.selected_col = 1;
app.active_filter = Some(state::ActiveFilter {
table: "many".to_string(),
sql: "SELECT * FROM many".to_string(),
});
set_cell_edit_text(&mut app, "updated");
commit_value(&mut app, &mut operations);
finish_operation(&mut app, &mut operations).await;
finish_operation(&mut app, &mut operations).await;
assert_eq!(app.grid.rows.len(), 1_000);
assert!(app.status.contains("1000+ rows (truncated)"));
}
async fn finish_operation(app: &mut AppState, operations: &mut Operations) {
let message = operations.receiver.recv().await.unwrap();
apply_operation_message(app, operations, message);
}
async fn execute_and_finish(
app: &mut AppState,
operations: &mut Operations,
sql: &str,
table: Option<String>,
) {
execute_sql_for_table(app, operations, sql, table);
tokio::time::timeout(Duration::from_secs(5), finish_operation(app, operations))
.await
.unwrap_or_else(|_| panic!("operation timed out in test: {sql}"));
operations.after_draw(app);
}
fn set_cell_edit_text(app: &mut AppState, text: &str) {
app.focus = Panel::Grid;
dispatch(app, AppEvent::EditStart);
let Overlay::Edit { text: input, .. } = &mut app.overlay else {
panic!("expected cell edit overlay");
};
*input = text.to_string();
}
async fn app_with_pending_write() -> (AppState, Operations) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pending-write.sqlite");
crate::db::sqlite::ensure_demo_db(&path).await.unwrap();
let source = Arc::new(DataSource::Sqlite(
crate::db::sqlite::SqliteSource::connect(&path)
.await
.unwrap(),
));
let tables = source.list_tables().await.unwrap();
let mut app = AppState::new_with_access(tables, false);
let mut operations = Operations::new(source);
operations.start_with_impact(
&mut app,
"Writing",
OperationImpact::Write,
std::future::pending(),
);
(app, operations)
}
fn copy_app() -> AppState {
let mut app = AppState::new(vec!["people".to_string()]);
dispatch(
&mut app,
AppEvent::RowsLoaded {
table: Some("people".to_string()),
page: TablePage {
columns: vec![
Column {
name: "name".to_string(),
},
Column {
name: "note".to_string(),
},
],
rows: vec![
Row {
values: vec![
Value::Text("Ada".to_string()),
Value::Text("one,two".to_string()),
],
},
Row {
values: vec![
Value::Text("Grace".to_string()),
Value::Text("compiler".to_string()),
],
},
],
rowids: None,
offset: 0,
has_more: false,
},
},
);
app
}
}