1158 lines
46 KiB
Rust
1158 lines
46 KiB
Rust
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<Relation>,
|
||
active: Option<usize>,
|
||
columns: Vec<Column>,
|
||
page: Page,
|
||
page_index: usize,
|
||
rows_state: TableState,
|
||
selection_anchor: Option<(usize, usize)>,
|
||
copy_headers: Option<bool>,
|
||
copy_status: Option<String>,
|
||
schema_state: TableState,
|
||
column_offset: usize,
|
||
show_schema: bool,
|
||
query: Input,
|
||
query_insert: bool,
|
||
query_g: bool,
|
||
query_highlights: sql::Highlights,
|
||
query_result: Option<QueryResult>,
|
||
error: Option<String>,
|
||
}
|
||
|
||
impl App {
|
||
pub fn new(mut db: Database) -> Result<Self> {
|
||
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<String> {
|
||
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<usize>) {
|
||
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<Item = &'a str>,
|
||
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<usize>, count: usize, delta: isize) -> Option<usize> {
|
||
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(())
|
||
}
|
||
}
|