feat: add PostgreSQL connection form

This commit is contained in:
Valdemar Lorenzen
2026-10-19 12:22:11 +02:00
parent af7e8cad25
commit 8d533f13dc
4 changed files with 236 additions and 21 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
);
}
}
}
+4 -1
View File
@@ -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
View File
@@ -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.