feat: connect to SQLite from the TUI
This commit is contained in:
+4
-16
@@ -1,6 +1,6 @@
|
|||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout},
|
layout::{Constraint, Layout},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Line,
|
text::Line,
|
||||||
@@ -55,19 +55,7 @@ impl App {
|
|||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||||
loop {
|
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
|
||||||
if let Event::Key(key) = event::read()?
|
|
||||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
||||||
&& self.handle_key(key)
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
|
||||||
if key.code == KeyCode::Char('q')
|
if key.code == KeyCode::Char('q')
|
||||||
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
|
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
|
||||||
{
|
{
|
||||||
@@ -183,7 +171,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
pub(crate) fn draw(&mut self, frame: &mut Frame) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
if area.width < 70 || area.height < 12 {
|
if area.width < 70 || area.height < 12 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -360,7 +348,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(
|
frame.render_widget(Paragraph::new(
|
||||||
" Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · q/Ctrl-C quit"
|
" Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c connect · q quit"
|
||||||
).style(Style::default().fg(Color::DarkGray)), help);
|
).style(Style::default().fg(Color::DarkGray)), help);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
use std::{borrow::Cow, path::Path};
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, List, ListItem, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{Result, app::App, db::Database, error_text};
|
||||||
|
|
||||||
|
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
|
||||||
|
let mut browser = initial.map(App::new).transpose()?;
|
||||||
|
let mut form = Form::default();
|
||||||
|
let mut show_form = browser.is_none();
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
if show_form {
|
||||||
|
form.draw(frame, false);
|
||||||
|
} else if let Some(browser) = &mut browser {
|
||||||
|
browser.draw(frame);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
|
||||||
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if show_form {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
if browser.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
show_form = false;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
terminal.draw(|frame| form.draw(frame, true))?;
|
||||||
|
match form.connect().and_then(App::new) {
|
||||||
|
Ok(connected) => {
|
||||||
|
browser = Some(connected);
|
||||||
|
show_form = false;
|
||||||
|
}
|
||||||
|
Err(error) => form.error = Some(error_text(error.as_ref())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => form.path.edit(key),
|
||||||
|
}
|
||||||
|
} else if key.code == KeyCode::Char('c') {
|
||||||
|
form.error = None;
|
||||||
|
show_form = true;
|
||||||
|
} else if browser
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|browser| browser.handle_key(key))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Paste(text) if show_form => form.path.paste(&text),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Form {
|
||||||
|
path: Input,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Form {
|
||||||
|
fn connect(&self) -> Result<Database> {
|
||||||
|
if self.path.value.is_empty() {
|
||||||
|
return Err("Enter an existing SQLite database file path.".into());
|
||||||
|
}
|
||||||
|
Database::open_sqlite(Path::new(&self.path.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, frame: &mut Frame, connecting: bool) {
|
||||||
|
let area = frame.area();
|
||||||
|
if area.width < 70 || area.height < 12 {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("Enlarge terminal to at least 70×12. Ctrl-C to quit."),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let [header, body, status, help] = Layout::vertical([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(" dbdash │ New connection │ READ ONLY")
|
||||||
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
|
header,
|
||||||
|
);
|
||||||
|
let [providers, details] =
|
||||||
|
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
|
||||||
|
frame.render_widget(
|
||||||
|
List::new([ListItem::new(" SQLite · local file")
|
||||||
|
.style(Style::default().bg(Color::Blue).fg(Color::White))])
|
||||||
|
.block(Block::bordered().title(" Database type ")),
|
||||||
|
providers,
|
||||||
|
);
|
||||||
|
let [path, description] =
|
||||||
|
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
||||||
|
self.path
|
||||||
|
.draw(frame, path, " Database file path ", false, true);
|
||||||
|
frame.render_widget(Paragraph::new("Open an existing SQLite file.\nFiles are never created or modified.\n\nConnection details stay in this session.").wrap(Wrap { trim: false }), description);
|
||||||
|
let message = if connecting {
|
||||||
|
"Connecting…"
|
||||||
|
} else {
|
||||||
|
self.error.as_deref().unwrap_or("Not connected to a new database. The current connection stays open until this succeeds.")
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(message)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.style(Style::default().fg(if self.error.is_some() && !connecting {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::Cyan
|
||||||
|
})),
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
frame.render_widget(Paragraph::new(" Enter connect · Esc cancel/quit · Ctrl-C quit\n ←→ Home/End edit · Backspace/Delete erase · Ctrl-U clear").style(Style::default().fg(Color::DarkGray)), help);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Input {
|
||||||
|
value: String,
|
||||||
|
cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
fn edit(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.cursor = self.value[..self.cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map_or(0, |(index, _)| index)
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.cursor += self.value[self.cursor..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map_or(0, char::len_utf8)
|
||||||
|
}
|
||||||
|
KeyCode::Home => self.cursor = 0,
|
||||||
|
KeyCode::End => self.cursor = self.value.len(),
|
||||||
|
KeyCode::Backspace if self.cursor > 0 => {
|
||||||
|
let previous = self.value[..self.cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
self.value.drain(previous..self.cursor);
|
||||||
|
self.cursor = previous;
|
||||||
|
}
|
||||||
|
KeyCode::Delete if self.cursor < self.value.len() => {
|
||||||
|
self.value.remove(self.cursor);
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.value.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char(character)
|
||||||
|
if !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
&& !character.is_control() =>
|
||||||
|
{
|
||||||
|
self.value.insert(self.cursor, character);
|
||||||
|
self.cursor += character.len_utf8();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paste(&mut self, value: &str) {
|
||||||
|
let text: String = value
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.collect();
|
||||||
|
self.value.insert_str(self.cursor, &text);
|
||||||
|
self.cursor += text.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool) {
|
||||||
|
let text: Cow<'_, str> = if secret {
|
||||||
|
Cow::Owned("•".repeat(self.value.chars().count()))
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(&self.value)
|
||||||
|
};
|
||||||
|
let before_cursor = if secret {
|
||||||
|
self.value[..self.cursor].chars().count()
|
||||||
|
} else {
|
||||||
|
Line::raw(&self.value[..self.cursor]).width()
|
||||||
|
};
|
||||||
|
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
|
||||||
|
let offset = cursor.saturating_sub(area.width.saturating_sub(3));
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(text).scroll((0, offset)).block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(label)
|
||||||
|
.border_style(Style::default().fg(if focused {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
if focused && area.width > 2 && area.height > 2 {
|
||||||
|
frame.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_preserves_unicode_and_filters_terminal_controls() {
|
||||||
|
let mut input = Input::default();
|
||||||
|
input.paste("aé🙂z\n\u{1b}");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
|
assert_eq!(input.value, "aéz");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||||||
|
assert_eq!(input.value, "aé界");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
|
input.paste("/tmp/");
|
||||||
|
assert_eq!(input.value, "/tmp/aé界");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||||
|
assert_eq!(input.cursor, input.value.len());
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||||
|
assert!(input.value.is_empty());
|
||||||
|
assert_eq!(input.cursor, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod connection;
|
||||||
mod db;
|
mod db;
|
||||||
|
|
||||||
use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode};
|
use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode};
|
||||||
@@ -12,7 +13,7 @@ Usage:
|
|||||||
dbdash --sqlite PATH
|
dbdash --sqlite PATH
|
||||||
dbdash --help
|
dbdash --help
|
||||||
|
|
||||||
With no argument, the connection is read from DATABASE_URL.
|
With no argument or DATABASE_URL, the in-app connection screen opens.
|
||||||
Prefer the environment variable to keep passwords out of process arguments:
|
Prefer the environment variable to keep passwords out of process arguments:
|
||||||
export DATABASE_URL='postgresql://user@localhost:5432/database'
|
export DATABASE_URL='postgresql://user@localhost:5432/database'
|
||||||
cargo run
|
cargo run
|
||||||
@@ -33,6 +34,7 @@ Navigation:
|
|||||||
s Toggle rows and column schema
|
s Toggle rows and column schema
|
||||||
Left/Right Scroll columns (h/l also work)
|
Left/Right Scroll columns (h/l also work)
|
||||||
n/p, PgDn/Up Next / previous 100-row page
|
n/p, PgDn/Up Next / previous 100-row page
|
||||||
|
c Open the connection screen (Esc keeps the current database)
|
||||||
r Refresh relations and the active relation
|
r Refresh relations and the active relation
|
||||||
q / Ctrl-C Quit
|
q / Ctrl-C Quit
|
||||||
|
|
||||||
@@ -66,11 +68,12 @@ fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
|
let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
|
||||||
let connection = if sqlite {
|
let connection = if sqlite {
|
||||||
|
Some(
|
||||||
args.next()
|
args.next()
|
||||||
.ok_or("--sqlite requires a database file path; see dbdash --help")?
|
.ok_or("--sqlite requires a database file path; see dbdash --help")?,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
argument.or_else(|| std::env::var_os("DATABASE_URL"))
|
argument.or_else(|| std::env::var_os("DATABASE_URL"))
|
||||||
.ok_or("set DATABASE_URL, pass a PostgreSQL connection string, or use --sqlite PATH; see dbdash --help")?
|
|
||||||
};
|
};
|
||||||
if args.next().is_some() {
|
if args.next().is_some() {
|
||||||
return Err("unexpected extra arguments; see dbdash --help".into());
|
return Err("unexpected extra arguments; see dbdash --help".into());
|
||||||
@@ -78,22 +81,30 @@ fn run() -> Result<()> {
|
|||||||
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
||||||
return Err("an interactive terminal is required; see dbdash --help".into());
|
return Err("an interactive terminal is required; see dbdash --help".into());
|
||||||
}
|
}
|
||||||
let database = if sqlite {
|
let database = connection
|
||||||
db::Database::open_sqlite(Path::new(&connection))?
|
.map(|connection| {
|
||||||
|
if sqlite {
|
||||||
|
db::Database::open_sqlite(Path::new(&connection))
|
||||||
} else {
|
} else {
|
||||||
db::Database::connect(
|
db::Database::connect(
|
||||||
connection
|
connection
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or("connection string must be UTF-8")?,
|
.ok_or("connection string must be UTF-8")?,
|
||||||
)?
|
)
|
||||||
};
|
}
|
||||||
let mut app = app::App::new(database)?;
|
})
|
||||||
|
.transpose()?;
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
let mut terminal = ratatui::try_init()?;
|
let mut terminal = ratatui::try_init()?;
|
||||||
app.run(&mut terminal)
|
crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste)?;
|
||||||
|
connection::run(&mut terminal, database)
|
||||||
})();
|
})();
|
||||||
|
let paste_result =
|
||||||
|
crossterm::execute!(std::io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
result
|
result?;
|
||||||
|
paste_result?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_text(error: &(dyn Error + 'static)) -> String {
|
fn error_text(error: &(dyn Error + 'static)) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user