feat: add PostgreSQL connection form
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout},
|
||||
|
||||
+218
-11
@@ -1,6 +1,7 @@
|
||||
use std::{borrow::Cow, path::Path};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use postgres::{Config, config::SslMode};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
@@ -34,22 +35,26 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
if browser.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
form.clear_password();
|
||||
show_form = false;
|
||||
}
|
||||
KeyCode::Enter if form.focus == 0 => form.focus = 1,
|
||||
KeyCode::Enter => {
|
||||
terminal.draw(|frame| form.draw(frame, true))?;
|
||||
match form.connect().and_then(App::new) {
|
||||
Ok(connected) => {
|
||||
form.clear_password();
|
||||
browser = Some(connected);
|
||||
show_form = false;
|
||||
}
|
||||
Err(error) => form.error = Some(error_text(error.as_ref())),
|
||||
}
|
||||
}
|
||||
_ => form.path.edit(key),
|
||||
_ => form.edit(key),
|
||||
}
|
||||
} else if key.code == KeyCode::Char('c') {
|
||||
form.error = None;
|
||||
form.focus = 0;
|
||||
show_form = true;
|
||||
} else if browser
|
||||
.as_mut()
|
||||
@@ -58,31 +63,116 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Event::Paste(text) if show_form => form.path.paste(&text),
|
||||
Event::Paste(text) if show_form => {
|
||||
form.error = None;
|
||||
if let Some(input) = form.input_mut() {
|
||||
input.paste(&text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Form {
|
||||
provider: usize,
|
||||
focus: usize,
|
||||
postgres: [Input; 5],
|
||||
tls: usize,
|
||||
path: Input,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Form {
|
||||
fn default() -> Self {
|
||||
let user = std::env::var("USER").unwrap_or_default();
|
||||
Self {
|
||||
provider: 0,
|
||||
focus: 0,
|
||||
tls: 0,
|
||||
postgres: ["localhost", "5432", "postgres", &user, ""].map(Input::from),
|
||||
path: Input::default(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn connect(&self) -> Result<Database> {
|
||||
if self.provider == 1 {
|
||||
if self.path.value.is_empty() {
|
||||
return Err("Enter an existing SQLite database file path.".into());
|
||||
}
|
||||
Database::open_sqlite(Path::new(&self.path.value))
|
||||
return Database::open_sqlite(Path::new(&self.path.value));
|
||||
}
|
||||
let [host, port, database, user, password] = &self.postgres;
|
||||
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
|
||||
return Err("PostgreSQL requires a host, database, and user.".into());
|
||||
}
|
||||
let port: u16 = port
|
||||
.value
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|port| *port > 0)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Port must be a number from 1 to 65535.",
|
||||
)
|
||||
})?;
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.host(&host.value)
|
||||
.port(port)
|
||||
.dbname(&database.value)
|
||||
.user(&user.value)
|
||||
.ssl_mode([SslMode::Require, SslMode::Prefer, SslMode::Disable][self.tls]);
|
||||
if !password.value.is_empty() {
|
||||
config.password(&password.value);
|
||||
}
|
||||
Database::connect_postgres(config)
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> Option<&mut Input> {
|
||||
if self.provider == 1 {
|
||||
(self.focus == 1).then_some(&mut self.path)
|
||||
} else {
|
||||
self.postgres.get_mut(self.focus.checked_sub(1)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_password(&mut self) {
|
||||
self.postgres[4] = Input::default();
|
||||
}
|
||||
|
||||
fn edit(&mut self, key: KeyEvent) {
|
||||
let count = if self.provider == 0 { 7 } else { 2 };
|
||||
match key.code {
|
||||
KeyCode::Tab => self.focus = (self.focus + 1) % count,
|
||||
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
|
||||
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => {
|
||||
self.provider = 1 - self.provider
|
||||
}
|
||||
KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 6 => {
|
||||
self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3;
|
||||
}
|
||||
_ => {
|
||||
if let Some(input) = self.input_mut() {
|
||||
input.edit(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut Frame, connecting: bool) {
|
||||
let area = frame.area();
|
||||
if area.width < 70 || area.height < 12 {
|
||||
let minimum_height = if self.provider == 0 { 16 } else { 12 };
|
||||
if area.width < 70 || area.height < minimum_height {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enlarge terminal to at least 70×12. Ctrl-C to quit."),
|
||||
Paragraph::new(format!(
|
||||
"Enlarge terminal to at least 70×{minimum_height}. Ctrl-C to quit."
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
@@ -102,16 +192,87 @@ impl Form {
|
||||
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 ")),
|
||||
List::new(
|
||||
[" PostgreSQL · server", " SQLite · local file"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| {
|
||||
ListItem::new(name).style(if index == self.provider {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
}),
|
||||
)
|
||||
.block(Block::bordered().title(" Database type ").border_style(
|
||||
Style::default().fg(if self.focus == 0 {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}),
|
||||
)),
|
||||
providers,
|
||||
);
|
||||
if self.provider == 1 {
|
||||
let [path, description] =
|
||||
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
||||
self.path
|
||||
.draw(frame, path, " Database file path ", false, true);
|
||||
.draw(frame, path, " Database file path ", false, self.focus == 1);
|
||||
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);
|
||||
} else {
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(details);
|
||||
let labels = [
|
||||
" Host / socket directory ",
|
||||
" Port ",
|
||||
" Database ",
|
||||
" User ",
|
||||
" Password ",
|
||||
];
|
||||
let pairs: [[Rect; 2]; 3] = std::array::from_fn(|index| {
|
||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.areas(rows[index])
|
||||
});
|
||||
for (index, input) in self.postgres.iter().enumerate() {
|
||||
input.draw(
|
||||
frame,
|
||||
pairs[index / 2][index % 2],
|
||||
labels[index],
|
||||
index == 4,
|
||||
self.focus == index + 1,
|
||||
);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block(
|
||||
Block::bordered().title(" TLS · ←/→ change ").border_style(
|
||||
Style::default().fg(if self.focus == 6 {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}),
|
||||
),
|
||||
),
|
||||
pairs[2][1],
|
||||
);
|
||||
let tls_notice = match self.tls {
|
||||
0 => "TLS required; certificates and hostnames are verified.",
|
||||
1 => "TLS preferred; the server may fall back to unencrypted traffic.",
|
||||
_ => "TLS disabled: traffic is unencrypted. Use only on trusted networks.",
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::raw(tls_notice),
|
||||
Line::raw("Passwords are masked and never saved to disk."),
|
||||
])
|
||||
.wrap(Wrap { trim: false }),
|
||||
rows[3],
|
||||
);
|
||||
}
|
||||
let message = if connecting {
|
||||
"Connecting…"
|
||||
} else {
|
||||
@@ -127,7 +288,7 @@ impl Form {
|
||||
})),
|
||||
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);
|
||||
frame.render_widget(Paragraph::new(" Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit").style(Style::default().fg(Color::DarkGray)), help);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +298,15 @@ struct Input {
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl From<&str> for Input {
|
||||
fn from(value: &str) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
cursor: value.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn edit(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
@@ -248,4 +418,41 @@ mod tests {
|
||||
assert!(input.value.is_empty());
|
||||
assert_eq!(input.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
||||
let mut form = Form {
|
||||
focus: 5,
|
||||
..Form::default()
|
||||
};
|
||||
form.postgres[4] = Input::from("test-secret-é");
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?;
|
||||
terminal.draw(|frame| form.draw(frame, false))?;
|
||||
let rendered: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(!rendered.contains("test-secret-é"));
|
||||
assert!(rendered.contains("•••••••••••••"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ports_are_rejected_before_connecting() {
|
||||
for port in ["0", "65536", "not-a-port"] {
|
||||
let mut form = Form::default();
|
||||
form.postgres[1] = Input::from(port);
|
||||
form.postgres[3] = Input::from("inspector");
|
||||
let error = form.connect().err().unwrap();
|
||||
assert_eq!(
|
||||
error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.map(std::io::Error::kind),
|
||||
Some(std::io::ErrorKind::InvalidInput)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ pub struct Page {
|
||||
|
||||
impl Database {
|
||||
pub fn connect(connection: &str) -> Result<Self> {
|
||||
let mut config: Config = connection.parse()?;
|
||||
Self::connect_postgres(connection.parse()?)
|
||||
}
|
||||
|
||||
pub fn connect_postgres(mut config: Config) -> Result<Self> {
|
||||
if config.get_connect_timeout().is_none() {
|
||||
config.connect_timeout(Duration::from_secs(5));
|
||||
}
|
||||
|
||||
+7
-2
@@ -13,7 +13,8 @@ Usage:
|
||||
dbdash --sqlite PATH
|
||||
dbdash --help
|
||||
|
||||
With no argument or DATABASE_URL, the in-app connection screen opens.
|
||||
Run without a connection argument and without DATABASE_URL to connect in-app.
|
||||
Otherwise DATABASE_URL supplies the PostgreSQL connection when no argument is given.
|
||||
Prefer the environment variable to keep passwords out of process arguments:
|
||||
export DATABASE_URL='postgresql://user@localhost:5432/database'
|
||||
cargo run
|
||||
@@ -25,7 +26,7 @@ SQLite paths can be relative or absolute; quote paths containing spaces.
|
||||
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||
Use sslmode=disable only for trusted local connections.
|
||||
The terminal must be at least 70 columns wide and 12 lines high.
|
||||
The inspector needs 70×12 terminal cells; the PostgreSQL form needs 70×16.
|
||||
|
||||
Navigation:
|
||||
Tab / Esc Switch focus / return to relations
|
||||
@@ -37,6 +38,10 @@ Navigation:
|
||||
c Open the connection screen (Esc keeps the current database)
|
||||
r Refresh relations and the active relation
|
||||
q / Ctrl-C Quit
|
||||
Connection screen: Up/Down chooses a provider; Enter starts editing.
|
||||
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
|
||||
Enter connects; Esc cancels; Ctrl-U clears a field. Pasting is supported.
|
||||
Form details are session-only. Passwords clear after connecting or cancelling.
|
||||
|
||||
PostgreSQL reads are enforced read-only and have a 5-second query timeout.
|
||||
SQLite files are opened read-only, with a 5-second lock wait but no query timeout.
|
||||
|
||||
Reference in New Issue
Block a user