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::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout},
|
layout::{Constraint, Layout},
|
||||||
|
|||||||
+224
-17
@@ -1,6 +1,7 @@
|
|||||||
use std::{borrow::Cow, path::Path};
|
use std::{borrow::Cow, path::Path};
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
use postgres::{Config, config::SslMode};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
@@ -34,22 +35,26 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
if browser.is_none() {
|
if browser.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
form.clear_password();
|
||||||
show_form = false;
|
show_form = false;
|
||||||
}
|
}
|
||||||
|
KeyCode::Enter if form.focus == 0 => form.focus = 1,
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
terminal.draw(|frame| form.draw(frame, true))?;
|
terminal.draw(|frame| form.draw(frame, true))?;
|
||||||
match form.connect().and_then(App::new) {
|
match form.connect().and_then(App::new) {
|
||||||
Ok(connected) => {
|
Ok(connected) => {
|
||||||
|
form.clear_password();
|
||||||
browser = Some(connected);
|
browser = Some(connected);
|
||||||
show_form = false;
|
show_form = false;
|
||||||
}
|
}
|
||||||
Err(error) => form.error = Some(error_text(error.as_ref())),
|
Err(error) => form.error = Some(error_text(error.as_ref())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => form.path.edit(key),
|
_ => form.edit(key),
|
||||||
}
|
}
|
||||||
} else if key.code == KeyCode::Char('c') {
|
} else if key.code == KeyCode::Char('c') {
|
||||||
form.error = None;
|
form.error = None;
|
||||||
|
form.focus = 0;
|
||||||
show_form = true;
|
show_form = true;
|
||||||
} else if browser
|
} else if browser
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -58,31 +63,116 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
return Ok(());
|
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 {
|
struct Form {
|
||||||
|
provider: usize,
|
||||||
|
focus: usize,
|
||||||
|
postgres: [Input; 5],
|
||||||
|
tls: usize,
|
||||||
path: Input,
|
path: Input,
|
||||||
error: Option<String>,
|
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 {
|
impl Form {
|
||||||
fn connect(&self) -> Result<Database> {
|
fn connect(&self) -> Result<Database> {
|
||||||
if self.path.value.is_empty() {
|
if self.provider == 1 {
|
||||||
return Err("Enter an existing SQLite database file path.".into());
|
if self.path.value.is_empty() {
|
||||||
|
return Err("Enter an existing SQLite database file path.".into());
|
||||||
|
}
|
||||||
|
return Database::open_sqlite(Path::new(&self.path.value));
|
||||||
}
|
}
|
||||||
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) {
|
fn draw(&self, frame: &mut Frame, connecting: bool) {
|
||||||
let area = frame.area();
|
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(
|
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,
|
area,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -102,16 +192,87 @@ impl Form {
|
|||||||
let [providers, details] =
|
let [providers, details] =
|
||||||
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
|
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
List::new([ListItem::new(" SQLite · local file")
|
List::new(
|
||||||
.style(Style::default().bg(Color::Blue).fg(Color::White))])
|
[" PostgreSQL · server", " SQLite · local file"]
|
||||||
.block(Block::bordered().title(" Database type ")),
|
.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,
|
providers,
|
||||||
);
|
);
|
||||||
let [path, description] =
|
if self.provider == 1 {
|
||||||
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
let [path, description] =
|
||||||
self.path
|
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
||||||
.draw(frame, path, " Database file path ", false, true);
|
self.path
|
||||||
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);
|
.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 {
|
let message = if connecting {
|
||||||
"Connecting…"
|
"Connecting…"
|
||||||
} else {
|
} else {
|
||||||
@@ -127,7 +288,7 @@ impl Form {
|
|||||||
})),
|
})),
|
||||||
status,
|
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,
|
cursor: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Input {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
value: value.into(),
|
||||||
|
cursor: value.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
fn edit(&mut self, key: KeyEvent) {
|
fn edit(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -248,4 +418,41 @@ mod tests {
|
|||||||
assert!(input.value.is_empty());
|
assert!(input.value.is_empty());
|
||||||
assert_eq!(input.cursor, 0);
|
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 {
|
impl Database {
|
||||||
pub fn connect(connection: &str) -> Result<Self> {
|
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() {
|
if config.get_connect_timeout().is_none() {
|
||||||
config.connect_timeout(Duration::from_secs(5));
|
config.connect_timeout(Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -13,7 +13,8 @@ Usage:
|
|||||||
dbdash --sqlite PATH
|
dbdash --sqlite PATH
|
||||||
dbdash --help
|
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:
|
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
|
||||||
@@ -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.
|
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
||||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||||
Use sslmode=disable only for trusted local connections.
|
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:
|
Navigation:
|
||||||
Tab / Esc Switch focus / return to relations
|
Tab / Esc Switch focus / return to relations
|
||||||
@@ -37,6 +38,10 @@ Navigation:
|
|||||||
c Open the connection screen (Esc keeps the current database)
|
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
|
||||||
|
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.
|
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.
|
SQLite files are opened read-only, with a 5-second lock wait but no query timeout.
|
||||||
|
|||||||
Reference in New Issue
Block a user