use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Cell, Paragraph, Row, Table, TableState}, }; use crate::{ Result, connection::Input, db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation}, error_text, sql, }; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Focus { Explorer, Query, Rows, } pub struct App { db: Database, pub(crate) relations: Vec, active: Option, columns: Vec, page: Page, page_index: usize, rows_state: TableState, selection_anchor: Option<(usize, usize)>, copy_headers: Option, copy_status: Option, schema_state: TableState, column_offset: usize, show_schema: bool, query: Input, query_insert: bool, query_g: bool, query_highlights: sql::Highlights, query_result: Option, error: Option, } impl App { pub fn new(mut db: Database) -> Result { let relations = db.relations()?; let selected = (!relations.is_empty()).then_some(0); let mut app = Self { db, relations, active: None, columns: Vec::new(), page: Page { rows: Vec::new(), has_next: false, }, page_index: 0, rows_state: TableState::default(), selection_anchor: None, copy_headers: None, copy_status: None, schema_state: TableState::default(), column_offset: 0, show_schema: false, query: Input::default(), query_insert: false, query_g: false, query_highlights: Vec::new(), query_result: None, error: None, }; app.open_relation(selected); Ok(app) } pub(crate) fn provider(&self) -> &'static str { self.db.name() } pub(crate) fn access_mode(&self) -> crate::db::AccessMode { self.db.access_mode() } pub(crate) fn database_name(&self) -> &str { self.db.display_name() } pub(crate) fn query_insert_mode(&self) -> bool { self.query_insert } pub(crate) fn leave_query_insert(&mut self) -> bool { self.query_g = false; std::mem::replace(&mut self.query_insert, false) } pub(crate) fn query_position(&self) -> (usize, usize) { self.query.position() } pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool { let pending = self.copy_headers.take(); if self.show_schema { return false; } if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL) { return false; } if let Some(headers) = pending { match key.code { KeyCode::Char(format @ ('m' | 'c')) => { if let Some(text) = self.selection_text(headers, format == 'm') { match copy_to_clipboard(&text) { Ok(()) => { self.error = None; self.copy_status = Some(format!( " Copied {} {} headers ", if format == 'm' { "Markdown" } else { "CSV" }, if headers { "with" } else { "without" }, )); } Err(error) => { self.error = Some(format!("Clipboard: {}", error_text(error.as_ref()))); } } } return true; } KeyCode::Esc => return true, _ => {} } } match key.code { KeyCode::Char('v') => { self.copy_status = None; self.selection_anchor = if self.selection_anchor.is_some() { None } else { self.rows_state.selected_cell() }; true } KeyCode::Char(character @ ('y' | 'Y')) => { self.copy_status = None; if self.selection_bounds().is_some() { self.copy_headers = Some(character == 'y'); } true } KeyCode::Esc if self.selection_anchor.is_some() => { self.selection_anchor = None; self.copy_status = None; true } _ => false, } } fn clear_selection(&mut self) { self.selection_anchor = None; self.copy_headers = None; self.copy_status = None; } fn selection_bounds(&self) -> Option<(usize, usize, usize, usize)> { if self.show_schema { return None; } let (row, column) = self.rows_state.selected_cell()?; let (anchor_row, anchor_column) = self.selection_anchor.unwrap_or((row, column)); Some(( row.min(anchor_row), row.max(anchor_row), column.min(anchor_column), column.max(anchor_column), )) } fn selection_text(&self, headers: bool, markdown: bool) -> Option { let (top, bottom, left, right) = self.selection_bounds()?; let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page); let rows = page.rows.get(top..=bottom)?; let mut output = String::new(); if headers { append_record( &mut output, (left..=right).map(|column| { self.query_result.as_ref().map_or_else( || self.columns[column].label.as_str(), |result| result.headers[column].as_str(), ) }), markdown, ); } else if markdown { append_record(&mut output, (left..=right).map(|_| ""), true); } if markdown { append_record(&mut output, (left..=right).map(|_| "---"), true); } for row in rows { append_record( &mut output, row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")), markdown, ); } Some(output) } pub(crate) fn handle_key(&mut self, key: KeyEvent) { self.copy_status = None; match key.code { KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX), KeyCode::Char('s') if self.query_result.is_none() => { self.clear_selection(); self.show_schema = !self.show_schema; self.column_offset = 0; } KeyCode::Left | KeyCode::Char('h') => self.move_column(-1), KeyCode::Right | KeyCode::Char('l') => self.move_column(1), KeyCode::Char('n') | KeyCode::PageDown if self.query_result.is_none() && !self.show_schema && self.page.has_next => { self.load_page(self.page_index + 1); } KeyCode::Char('p') | KeyCode::PageUp if self.query_result.is_none() && !self.show_schema && self.page_index > 0 => { self.load_page(self.page_index - 1); } KeyCode::Char('r') => self.refresh(), _ => {} } } pub(crate) fn handle_query_key(&mut self, key: KeyEvent) { let previous_len = self.query.value.len(); let pending_g = std::mem::take(&mut self.query_g); match key.code { KeyCode::F(5) => self.execute_query(), KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(), KeyCode::Esc => { self.leave_query_insert(); } KeyCode::Enter if self.query_insert => self.query.paste_multiline("\n"), _ if self.query_insert => self.query.edit(key), KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End => self.query.edit(key), KeyCode::Char(character) if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { let movement = match character { 'h' => Some(KeyCode::Left), 'j' => Some(KeyCode::Down), 'k' => Some(KeyCode::Up), 'l' => Some(KeyCode::Right), '0' => Some(KeyCode::Home), '$' => Some(KeyCode::End), '^' | 'w' | 'b' | 'e' | 'G' => { self.query.vim_motion(character); None } 'g' => { if pending_g { self.query.vim_motion('g'); } else { self.query_g = true; } None } 'a' => { let (line, column) = self.query_position(); self.query .value .split('\n') .nth(line - 1) .filter(|text| column <= text.chars().count()) .map(|_| KeyCode::Right) } 'I' => Some(KeyCode::Home), 'A' => Some(KeyCode::End), _ => None, }; if let Some(code) = movement { self.query.edit(KeyEvent::new(code, KeyModifiers::NONE)); } self.query_insert = matches!(character, 'i' | 'a' | 'I' | 'A'); } _ => {} } self.highlight_query(previous_len); } pub(crate) fn paste_query(&mut self, text: &str) { if !self.query_insert { return; } let previous_len = self.query.value.len(); self.query.paste_multiline(text); self.highlight_query(previous_len); } fn highlight_query(&mut self, previous_len: usize) { // Editing inserts or removes bytes; cursor movement does not invalidate syntax. if self.query.value.len() != previous_len { self.query_highlights = sql::highlight(&self.query.value, self.db.name() == "SQLite"); } } fn execute_query(&mut self) { match self.db.query(&self.query.value) { Ok(result) => { self.clear_selection(); self.error = None; if self.db.access_mode() == crate::db::AccessMode::ReadWrite { self.refresh(); } self.rows_state = TableState::default() .with_selected((!result.page.rows.is_empty()).then_some(0)) .with_selected_column((!result.headers.is_empty()).then_some(0)); self.column_offset = 0; self.show_schema = false; self.query_result = Some(result); } Err(error) => self.error = Some(error_text(error.as_ref())), } } fn move_selection(&mut self, delta: isize) { if self.show_schema { let next = moved_index(self.schema_state.selected(), self.columns.len(), delta); self.schema_state.select(next); } else { let count = self .query_result .as_ref() .map_or(self.page.rows.len(), |result| result.page.rows.len()); let next = moved_index(self.rows_state.selected(), count, delta); self.rows_state.select(next); } } fn column_count(&self) -> usize { if self.show_schema { 5 } else { self.query_result .as_ref() .map_or(self.columns.len(), |result| result.headers.len()) } } fn move_column(&mut self, delta: isize) { let count = self.column_count(); let state = if self.show_schema { &mut self.schema_state } else { &mut self.rows_state }; state.select_column(moved_index(state.selected_column(), count, delta)); } pub(crate) fn open_relation(&mut self, index: Option) { self.clear_selection(); self.active = index; self.query_result = None; self.columns.clear(); self.page = Page { rows: Vec::new(), has_next: false, }; self.page_index = 0; self.column_offset = 0; self.rows_state = TableState::default(); self.schema_state = TableState::default(); self.error = None; if let Some(index) = self.active { match self.db.columns(&self.relations[index]) { Ok(columns) => { self.columns = columns; self.schema_state .select((!self.columns.is_empty()).then_some(0)); self.schema_state .select_column((!self.columns.is_empty()).then_some(0)); self.load_page(0); } Err(error) => self.error = Some(error_text(error.as_ref())), } } } fn load_page(&mut self, index: usize) { if let Some(active) = self.active { // ponytail: synchronous reads block input; use a worker if responsiveness matters. match self.db.page(&self.relations[active], &self.columns, index) { Ok(page) => { self.clear_selection(); self.rows_state = TableState::default() .with_selected((!page.rows.is_empty()).then_some(0)) .with_selected_column( self.rows_state .selected_column() .or_else(|| (!self.columns.is_empty()).then_some(0)), ); self.page = page; self.page_index = index; self.error = None; } Err(error) => self.error = Some(error_text(error.as_ref())), } } } fn refresh(&mut self) { match self.db.relations() { Ok(relations) => { let id = self.active.map(|index| self.relations[index].id); let selected = relations .iter() .position(|relation| Some(relation.id) == id) .or_else(|| (!relations.is_empty()).then_some(0)); self.relations = relations; self.open_relation(selected); } Err(error) => self.error = Some(error_text(error.as_ref())), } } pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: Focus) { if area.width < 38 || area.height < 10 { frame.render_widget( Paragraph::new("Enlarge terminal to at least 70×17. Ctrl-C to quit."), area, ); return; } let [error_area, body] = Layout::vertical([ Constraint::Length(u16::from(self.error.is_some())), Constraint::Min(4), ]) .areas(area); let [query, content] = Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body); self.query.draw( frame, query, " Query · F5 execute ", false, focus == Focus::Query, Some(sql::text(&self.query.value, &self.query_highlights)), ); let title = if let Some(headers) = self.copy_headers { if headers { " Copy with headers · m Markdown / c CSV " } else { " Copy without headers · m Markdown / c CSV " } } else if self.selection_anchor.is_some() { " VISUAL BLOCK · ym/yc with headers · Ym/Yc without " } else if let Some(status) = &self.copy_status { status.as_str() } else if self.query_result.is_some() { " Results " } else if self.show_schema { " Schema · s for rows " } else { " Rows · s for schema " }; let count = self.column_count(); let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1); let selected = if self.show_schema { self.schema_state.selected_column() } else { self.rows_state.selected_column() }; if let Some(selected) = selected { self.column_offset = self.column_offset.min(selected); if selected >= self.column_offset + capacity { self.column_offset = selected + 1 - capacity; } } let visible = capacity.min(count.saturating_sub(self.column_offset)); let widths = (0..visible).map(|index| { if index + 1 == visible { Constraint::Min(1) } else { Constraint::Length(20) } }); let description = if let Some(result) = &self.query_result { format!( " Query · {} rows{} · columns {}–{}/{}{}", result.page.rows.len(), if result.page.has_next { " (first 100)" } else { "" }, if count > 0 { self.column_offset + 1 } else { 0 }, self.column_offset + visible, count, result.affected_rows.map_or_else(String::new, |rows| format!(" · {rows} rows affected")), ) } else if let Some(index) = self.active { let relation = &self.relations[index]; if self.show_schema { format!( " [{}] · {} columns · fields {}–{}/5", relation.kind, self.columns.len(), self.column_offset + 1, self.column_offset + visible ) } else { let start = if self.page.rows.is_empty() { 0 } else { self.page_index * PAGE_SIZE + 1 }; let end = self.page_index * PAGE_SIZE + self.page.rows.len(); format!( " [{}] · rows {start}–{end} · page {}{} · columns {}–{}/{}", relation.kind, self.page_index + 1, if self.page.has_next { " (more)" } else { "" }, if count > 0 { self.column_offset + 1 } else { 0 }, self.column_offset + visible, count ) } } else { " No readable relations. r to refresh.".into() }; let mut content_block = panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned()); if self.query_result.is_none() && let Some(index) = self.active { content_block = content_block.title_top( Line::from(format!(" {} ", self.relations[index].label)).right_aligned(), ); } let page = self .query_result .as_ref() .map_or(&self.page, |result| &result.page); if self.active.is_none() && self.query_result.is_none() { frame.render_widget( Paragraph::new("Choose a relation and press Enter.").block(content_block), content, ); } else if self.show_schema { let headers = ["Column", "Type", "Nullable", "Key", "Default"]; let rows = self.columns.iter().map(|column| { Row::new( [ column.label.as_str(), column.data_type.as_str(), if column.nullable { "yes" } else { "no" }, if column.primary_key { "PK" } else { "" }, column.default.as_str(), ] .into_iter() .skip(self.column_offset) .take(visible), ) }); let table = Table::new(rows, widths) .header( Row::new(headers.into_iter().skip(self.column_offset).take(visible)) .style(header_style()), ) .block(content_block) .cell_highlight_style(selected_style()); self.schema_state .select_column(selected.map(|column| column - self.column_offset)); frame.render_stateful_widget(table, content, &mut self.schema_state); self.schema_state.select_column(selected); } else if page.rows.is_empty() { frame.render_widget( Paragraph::new(if self.error.is_some() { "Could not load rows. See error above." } else { "No rows." }) .block(content_block), content, ); } else { let headers = (self.column_offset..self.column_offset + visible).map(|index| { self.query_result.as_ref().map_or_else( || self.columns[index].label.as_str(), |result| result.headers[index].as_str(), ) }); let bounds = self.selection_anchor.and_then(|_| self.selection_bounds()); let rows = page.rows.iter().enumerate().map(|(row_index, row)| { Row::new( row.iter() .enumerate() .skip(self.column_offset) .take(visible) .map(|(column_index, value)| { let cell = match value { Some(value) => Cell::from(value.as_str()), None => Cell::from("NULL").style( Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), ), }; if bounds.is_some_and(|(top, bottom, left, right)| { (top..=bottom).contains(&row_index) && (left..=right).contains(&column_index) }) { cell.style(selected_style()) } else { cell } }), ) }); let table = Table::new(rows, widths) .header(Row::new(headers).style(header_style())) .block(content_block) .cell_highlight_style(selected_style()); self.rows_state .select_column(selected.map(|column| column - self.column_offset)); frame.render_stateful_widget(table, content, &mut self.rows_state); self.rows_state.select_column(selected); } if let Some(error) = &self.error { frame.render_widget( Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), error_area, ); } } } fn append_record<'a>( output: &mut String, values: impl Iterator, markdown: bool, ) { if markdown { output.push('|'); } for (index, value) in values.enumerate() { if markdown { output.push(' '); for character in value.chars() { match character { '&' => output.push_str("&"), '<' => output.push_str("<"), '>' => output.push_str(">"), '\\' | '|' | '`' | '*' | '_' | '[' | ']' => { output.push('\\'); output.push(character); } _ => output.push(character), } } output.push_str(" |"); } else { if index > 0 { output.push(','); } if !value.contains([',', '"', '\r', '\n']) { output.push_str(value); continue; } output.push('"'); for character in value.chars() { if character == '"' { output.push('"'); } output.push(character); } output.push('"'); } } output.push_str(if markdown { "\n" } else { "\r\n" }); } fn copy_to_clipboard(text: &str) -> Result<()> { use std::{io::Write, process::{Command, Stdio}}; let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { ("pbcopy", &[]) } else if cfg!(target_os = "windows") { ("powershell.exe", &["-NoProfile", "-Command", "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"]) } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { ("wl-copy", &[]) } else { ("xclip", &["-selection", "clipboard"]) }; let mut child = Command::new(program) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|error| format!("could not start {program}: {error}"))?; let written = child.stdin.take().unwrap().write_all(text.as_bytes()); let status = child.wait()?; if !status.success() { return Err(format!("{program} failed ({status})").into()); } written?; Ok(()) } fn panel(title: &str, focused: bool) -> Block<'_> { Block::bordered() .title(title) .border_style(Style::default().fg(if focused { Color::Cyan } else { Color::DarkGray })) } fn selected_style() -> Style { Style::default().bg(Color::Blue).fg(Color::White) } fn header_style() -> Style { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } fn moved_index(selected: Option, count: usize, delta: isize) -> Option { if count == 0 { return None; } Some( selected .unwrap_or(0) .saturating_add_signed(delta) .min(count - 1), ) } #[cfg(test)] mod tests { use super::moved_index; #[test] fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() { let mut output = String::new(); super::append_record( &mut output, ["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(), false, ); assert_eq!(output, "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n"); } #[test] fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> crate::Result<()> { use super::{App, Focus}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, style::Color}; let file = tempfile::NamedTempFile::new()?; rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE cells (a, b, c, d); INSERT INTO cells VALUES ('outside', 'a|b', 'say \"hi\",界', 'outside'), ('outside', NULL, '<&>\\tail', 'outside'), ('outside', 'outside', 'outside', 'outside'); WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101) INSERT INTO cells SELECT x,x,x,x FROM n;", )?; let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); // Anchor at the bottom right, extend up and left across a scrolled viewport. for character in ['j', 'l', 'l'] { app.handle_key(key(character)); } let mut terminal = Terminal::new(TestBackend::new(60, 20))?; terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?; assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL))); app.handle_key(key('k')); app.handle_key(key('h')); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?; let buffer = terminal.backend().buffer(); assert_eq!(app.column_offset, 1); for position in [(1, 12), (22, 12), (1, 13), (22, 13)] { assert_eq!(buffer[position].bg, Color::Blue); } assert_ne!(buffer[(1, 14)].bg, Color::Blue); assert_eq!( app.selection_text(true, true).as_deref(), Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") ); assert_eq!( app.selection_text(false, true).as_deref(), Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") ); assert_eq!( app.selection_text(true, false).as_deref(), Some("b,c\r\na|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n") ); assert_eq!( app.selection_text(false, false).as_deref(), Some("a|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n") ); assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); assert_eq!(app.selection_text(false, false).as_deref(), Some("a|b\r\n")); // A prefix cancels on focus changes rather than capturing a later key. app.handle_rows_command(key('y')); assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))); assert!(app.copy_headers.is_none()); app.handle_rows_command(key('v')); app.handle_key(key('n')); assert!(app.selection_anchor.is_none()); assert_eq!(app.selection_text(false, false).as_deref(), Some("101\r\n")); app.handle_rows_command(key('v')); app.handle_key(key('s')); assert!(app.selection_anchor.is_none()); assert!(app.selection_text(true, true).is_none()); app.handle_key(key('s')); app.handle_rows_command(key('v')); app.query.value = "SELECT 42 AS answer".into(); app.execute_query(); assert!(app.selection_anchor.is_none()); assert_eq!(app.selection_text(true, false).as_deref(), Some("answer\r\n42\r\n")); app.handle_rows_command(key('v')); app.query.value = "SELECT missing FROM nowhere".into(); app.execute_query(); assert_eq!(app.selection_text(false, false).as_deref(), Some("42\r\n")); assert!(app.selection_anchor.is_some()); app.open_relation(Some(0)); assert!(app.selection_anchor.is_none()); app.query.value = "SELECT * FROM cells WHERE 0".into(); app.execute_query(); app.handle_rows_command(key('v')); assert!(app.selection_text(true, true).is_none()); Ok(()) } #[test] fn navigation_clamps_to_available_items() { assert_eq!(moved_index(None, 0, 1), None); assert_eq!(moved_index(None, 3, 1), Some(1)); assert_eq!(moved_index(Some(0), 3, -1), Some(0)); assert_eq!(moved_index(Some(2), 3, 1), Some(2)); assert_eq!(moved_index(Some(1), 3, isize::MIN), Some(0)); assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2)); } #[test] fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); app.handle_query_key(key('i')); app.paste_query(" SELECT café,界\n\n FROM data"); app.leave_query_insert(); for (motion, expected) in [ ('g', (3, 12)), ('g', (1, 1)), ('^', (1, 3)), ('e', (1, 8)), ('w', (1, 10)), ('e', (1, 13)), ('w', (1, 14)), ('w', (1, 15)), ('w', (3, 3)), ('b', (1, 15)), ('b', (1, 14)), ('b', (1, 10)), ('0', (1, 1)), ('$', (1, 16)), ('G', (3, 3)), ('e', (3, 6)), ('w', (3, 8)), ('w', (3, 12)), ('w', (3, 12)), ] { app.handle_query_key(key(motion)); assert_eq!(app.query_position(), expected, "motion {motion}"); assert!(!app.query_insert_mode()); } assert_eq!(app.query.value, " SELECT café,界\n\n FROM data"); app.handle_query_key(key('i')); for character in ['w', 'b', 'e', '0', '^', '$', 'g', 'G'] { app.handle_query_key(key(character)); } assert_eq!(app.query.value, " SELECT café,界\n\n FROM datawbe0^$gG"); app.leave_query_insert(); app.handle_query_key(key('g')); app.handle_query_key(key('0')); app.handle_query_key(key('g')); assert_eq!(app.query_position(), (3, 1)); Ok(()) } #[test] fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); assert!(!app.query_insert_mode()); app.paste_query("ignored"); app.handle_query_key(key('q')); assert!(app.query.value.is_empty()); assert!(!app.leave_query_insert()); app.handle_query_key(key('i')); for character in ['q', 'i', 'h'] { app.handle_query_key(key(character)); } app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); app.paste_query("界é"); assert_eq!(app.query.value, "qih\n界é"); assert_eq!(app.query_position(), (2, 3)); assert!(app.leave_query_insert()); app.paste_query("ignored"); app.handle_query_key(key('h')); assert_eq!(app.query_position(), (2, 2)); app.handle_query_key(key('k')); assert_eq!(app.query_position(), (1, 2)); app.handle_query_key(key('a')); assert!(app.query_insert_mode()); assert_eq!(app.query_position(), (1, 3)); app.leave_query_insert(); app.handle_query_key(key('I')); assert_eq!(app.query_position(), (1, 1)); assert!(app.query_insert_mode()); app.leave_query_insert(); app.handle_query_key(key('A')); assert_eq!(app.query_position(), (1, 4)); assert!(app.query_insert_mode()); app.leave_query_insert(); app.handle_query_key(key('a')); assert_eq!(app.query_position(), (1, 4)); app.handle_query_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!app.query_insert_mode()); assert_eq!(app.query.value, "qih\n界é"); Ok(()) } #[test] fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT\n42"); for insert in [true, false] { if !insert { app.leave_query_insert(); } for key in [ KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE), KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL), ] { app.query_result = None; app.handle_query_key(key); assert_eq!(app.query_insert_mode(), insert); assert_eq!( app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(), Some("42") ); } } Ok(()) } #[test] fn cell_selection_scrolls_and_survives_paging_and_schema_switches() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, style::Color}; let file = tempfile::NamedTempFile::new()?; rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE cells (a, b, c, d, e, f); WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101) INSERT INTO cells SELECT x, x, x, x, x, x FROM n;", )?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut terminal = Terminal::new(TestBackend::new(60, 20))?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; assert_eq!(terminal.backend().buffer()[(1, 12)].bg, Color::Blue); assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue); app.handle_key(key('l')); app.handle_key(key('l')); app.handle_key(key('j')); terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; assert_eq!(app.rows_state.selected_cell(), Some((1, 2))); assert_eq!(app.column_offset, 1); assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue); assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue); assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue); app.handle_key(key('n')); assert_eq!(app.page_index, 1); assert_eq!(app.rows_state.selected_cell(), Some((0, 2))); app.handle_key(key('s')); app.handle_key(key('l')); app.handle_key(key('j')); terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; assert_eq!(app.schema_state.selected_cell(), Some((1, 1))); assert_eq!(app.column_offset, 0); assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue); assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue); app.handle_key(key('s')); terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; assert_eq!(app.rows_state.selected_cell(), Some((0, 2))); assert_eq!(app.column_offset, 1); app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); assert_eq!(app.rows_state.selected_column(), Some(1)); Ok(()) } #[test] fn query_colors_survive_editing_and_scrolling() -> crate::Result<()> { use super::{App, Focus}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, style::Color}; let file = tempfile::NamedTempFile::new()?; let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut terminal = Terminal::new(TestBackend::new(60, 14))?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界', 42;\n-- comment"); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(3, 1)].fg, Color::Cyan); assert_eq!(buffer[(10, 1)].fg, Color::Green); assert_eq!(buffer[(3, 2)].fg, Color::DarkGray); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)); app.paste_query("unfinished\nstring"); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; assert_eq!(terminal.backend().buffer()[(3, 2)].fg, Color::Green); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90))); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; let cursor = terminal.get_cursor_position()?; let buffer = terminal.backend().buffer(); assert_eq!(cursor.x, 58); assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x"); assert_eq!(buffer[(4, cursor.y)].fg, Color::DarkGray); Ok(()) } #[test] fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);", )?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;"); app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); let result = app.query_result.as_ref().unwrap(); assert_eq!(result.headers, ["note", "missing"]); assert_eq!(result.page.rows[0], [Some("界é".into()), None]); assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!(app.rows_state.selected_cell(), Some((0, 1))); app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!(app.rows_state.selected(), Some(1)); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.paste_query("SELECT missing FROM nowhere"); app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); assert!(app.error.is_some()); assert_eq!( app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(), Some("界é") ); app.open_relation(Some(0)); assert!(app.query_result.is_none()); assert!(app.error.is_none()); assert_eq!(app.page.rows, [vec![Some("9".into())]]); assert_eq!(app.query.value, "SELECT missing FROM nowhere"); Ok(()) } #[test] fn relation_status_is_in_the_bottom_border() -> crate::Result<()> { let file = tempfile::NamedTempFile::new()?; rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);", )?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?; for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] { app.show_schema = schema; app.error = error.map(str::to_owned); terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; let buffer = terminal.backend().buffer(); let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect(); assert!(border.ends_with('┘'), "{border}"); assert!(!border.contains("main.comments")); assert!((0..19).any(|y| { let line: String = (0..100).map(|x| buffer[(x, y)].symbol()).collect(); line.ends_with(" main.comments ┐") })); assert!(border.contains(if schema { "fields 1–4/5" } else { "rows 1–1" })); if let Some(error) = error { let message: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect(); assert!(message.starts_with(error), "{message}"); } } Ok(()) } #[test] fn writes_refresh_browsing_and_keep_returning_results() -> crate::Result<()> { let file = tempfile::NamedTempFile::new()?; let mut app = super::App::new(crate::db::Database::open_sqlite( file.path(), crate::db::AccessMode::ReadWrite, )?)?; app.query.value = "CREATE TABLE edited (id INTEGER PRIMARY KEY)".into(); app.execute_query(); assert!(app.error.is_none(), "{:?}", app.error); assert_eq!(app.relations[0].name, "edited"); app.query.value = "INSERT INTO edited VALUES (42) RETURNING id".into(); app.execute_query(); assert!(app.error.is_none(), "{:?}", app.error); let result = app.query_result.as_ref().unwrap(); assert_eq!(result.affected_rows, Some(1)); assert_eq!(result.page.rows, [vec![Some("42".into())]]); app.open_relation(Some(0)); assert_eq!(app.page.rows, [vec![Some("42".into())]]); app.query.value = "DROP TABLE edited".into(); app.execute_query(); assert!(app.error.is_none(), "{:?}", app.error); assert!(app.relations.is_empty()); Ok(()) } }