feat: persist configured databases and connect from sidebar

This commit is contained in:
Valdemar Lorenzen
2026-10-19 13:10:27 +02:00
parent d8d7c1f2fc
commit 11647e0a8a
3 changed files with 325 additions and 74 deletions
+303 -48
View File
@@ -1,4 +1,7 @@
use std::{borrow::Cow, path::Path};
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use postgres::{Config, config::SslMode};
@@ -7,22 +10,43 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::Line,
widgets::{Block, Clear, List, ListItem, Paragraph, Wrap},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::{Result, app::App, db::Database, error_text};
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
let mut profiles = Profiles::open(&config_path()?)?;
let mut browser = initial.map(App::new).transpose()?;
let mut selection =
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
let mut databases_focus = true;
let mut active = None;
let mut error: Option<String> = None;
let mut form = Form::default();
let mut show_form = browser.is_none();
let mut show_form = false;
loop {
terminal.draw(|frame| {
let [sidebar, content] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)])
.areas(frame.area());
let items = profiles.entries.iter().enumerate().map(|(index, entry)| {
ListItem::new(format!("{}{}", if active == Some(index) { "● " } else { " " }, entry.label()))
});
let list = List::new(items)
.block(Block::bordered().title(" Databases · c add ").border_style(
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray })))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
frame.render_stateful_widget(list, sidebar, &mut selection);
if let Some(browser) = &mut browser {
browser.draw(frame);
browser.draw(frame, content, !databases_focus);
} else {
frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content);
}
if let Some(error) = &error {
frame.render_widget(Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), Rect::new(content.x, content.bottom().saturating_sub(1), content.width, 1));
}
if show_form {
form.draw(frame, false);
form.draw(frame);
}
})?;
match event::read()? {
@@ -33,40 +57,94 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
if show_form {
match key.code {
KeyCode::Esc => {
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| {
if let Some(browser) = &mut browser {
browser.draw(frame);
}
form.draw(frame, true);
})?;
match form.connect().and_then(App::new) {
Ok(connected) => {
form.clear_password();
let result = if form.reconnect {
form.connect().and_then(App::new).map(|connected| {
browser = Some(connected);
active = selection.selected();
databases_focus = false;
})
} else {
profiles.save(&form).map(|()| {
selection.select(Some(profiles.entries.len() - 1));
databases_focus = true;
})
};
match result {
Ok(()) => {
form.clear_password();
show_form = false;
error = None;
}
Err(error) => form.error = Some(error_text(error.as_ref())),
}
}
_ => form.edit(key),
}
} else if key.code == KeyCode::Char('c') {
form.error = None;
form.focus = 0;
show_form = true;
} else if browser
.as_mut()
.is_some_and(|browser| browser.handle_key(key))
{
return Ok(());
} else {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') => {
form = Form::default();
show_form = true;
}
KeyCode::Esc => databases_focus = true,
KeyCode::Tab | KeyCode::BackTab => {
if let Some(browser) = &mut browser {
let backwards = key.code == KeyCode::BackTab;
if databases_focus {
databases_focus = false;
browser.sidebar_focus = !backwards;
} else if browser.sidebar_focus != backwards {
browser.sidebar_focus = backwards;
} else {
databases_focus = true;
}
}
}
KeyCode::Enter if databases_focus => {
if let Some(index) = selection.selected() {
form = profiles.entries[index].clone();
if form.provider == 0 {
form.reconnect = true;
form.focus = 5;
show_form = true;
} else {
match form.connect().and_then(App::new) {
Ok(connected) => {
browser = Some(connected);
active = Some(index);
databases_focus = false;
error = None;
}
Err(failure) => error = Some(error_text(failure.as_ref())),
}
}
}
}
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
selection.select_next()
}
KeyCode::Up | KeyCode::Char('k') if databases_focus => {
selection.select_previous()
}
KeyCode::Home | KeyCode::Char('g') if databases_focus => {
selection.select_first()
}
KeyCode::End | KeyCode::Char('G') if databases_focus => {
selection.select_last()
}
_ if !databases_focus => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
}
_ => {}
}
}
}
Event::Paste(text) if show_form => {
@@ -80,6 +158,75 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
}
fn config_path() -> Result<PathBuf> {
let root = std::env::var_os("XDG_CONFIG_HOME")
.filter(|path| Path::new(path).is_absolute())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.ok_or("Set HOME or an absolute XDG_CONFIG_HOME to save database configurations.")?;
Ok(root.join("dbdash/connections.sqlite3"))
}
struct Profiles {
storage: rusqlite::Connection,
entries: Vec<Form>,
}
impl Profiles {
fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let storage = rusqlite::Connection::open(path)?;
storage.execute_batch(
"CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY, provider INTEGER NOT NULL CHECK(provider IN (0, 1)),
host TEXT NOT NULL, port TEXT NOT NULL, database_name TEXT NOT NULL,
username TEXT NOT NULL, tls INTEGER NOT NULL CHECK(tls BETWEEN 0 AND 2),
path TEXT NOT NULL
)",
)?;
let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path FROM connections ORDER BY id")?
.query_map([], |row| {
let mut form = Form {
provider: usize::from(row.get::<_, u8>(0)?),
tls: usize::from(row.get::<_, u8>(5)?),
path: Input::from(row.get::<_, String>(6)?.as_str()),
..Form::default()
};
for index in 0..4 {
form.postgres[index] = Input::from(row.get::<_, String>(index + 1)?.as_str());
}
Ok(form)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(Self { storage, entries })
}
fn save(&mut self, form: &Form) -> Result<()> {
let mut saved = form.clone();
saved.clear_password();
if form.provider == 0 {
form.postgres_config()?;
} else if form.path.value.is_empty() {
return Err("Enter a SQLite database file path.".into());
} else {
let path = shellexpand::path::full(Path::new(&form.path.value))?;
let absolute = if path.is_absolute() {
path.into_owned()
} else {
std::env::current_dir()?.join(path)
};
saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?);
}
self.storage.execute("INSERT INTO connections (provider, host, port, database_name, username, tls, path) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![saved.provider as u8, saved.postgres[0].value, saved.postgres[1].value, saved.postgres[2].value, saved.postgres[3].value, saved.tls as u8, saved.path.value])?;
self.entries.push(saved);
Ok(())
}
}
#[derive(Clone)]
struct Form {
provider: usize,
focus: usize,
@@ -87,6 +234,7 @@ struct Form {
tls: usize,
path: Input,
error: Option<String>,
reconnect: bool,
}
impl Default for Form {
@@ -98,6 +246,7 @@ impl Default for Form {
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
path: Input::default(),
error: None,
reconnect: false,
}
}
}
@@ -110,6 +259,10 @@ impl Form {
}
return Database::open_sqlite(Path::new(&self.path.value));
}
Database::connect_postgres(self.postgres_config()?)
}
fn postgres_config(&self) -> Result<Config> {
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());
@@ -135,14 +288,19 @@ impl Form {
if !password.value.is_empty() {
config.password(&password.value);
}
Database::connect_postgres(config)
Ok(config)
}
fn input_mut(&mut self) -> Option<&mut Input> {
if self.reconnect {
return Some(&mut self.postgres[4]);
}
if self.provider == 1 {
(self.focus == 1).then_some(&mut self.path)
} else {
self.postgres.get_mut(self.focus.checked_sub(1)?)
self.postgres
.get_mut(self.focus.checked_sub(1)?)
.filter(|_| self.focus < 5)
}
}
@@ -151,14 +309,19 @@ impl Form {
}
fn edit(&mut self, key: KeyEvent) {
let count = if self.provider == 0 { 7 } else { 2 };
if self.reconnect {
self.postgres[4].edit(key);
self.error = None;
return;
}
let count = if self.provider == 0 { 6 } 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 => {
KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 5 => {
self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3;
}
_ => {
@@ -170,7 +333,28 @@ impl Form {
self.error = None;
}
fn draw(&self, frame: &mut Frame, connecting: bool) {
fn label(&self) -> String {
crate::db::display_text(&if self.provider == 1 {
let path = Path::new(&self.path.value);
format!(
"{} · {}",
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy(),
self.path.value
)
} else {
format!(
"{} · {}@{}:{}",
self.postgres[2].value,
self.postgres[3].value,
self.postgres[0].value,
self.postgres[1].value
)
})
}
fn draw(&self, frame: &mut Frame) {
let screen = frame.area();
let minimum_height = if self.provider == 0 { 20 } else { 16 };
if screen.width < 74 || screen.height < minimum_height {
@@ -189,7 +373,11 @@ impl Form {
screen.width.saturating_sub(4).min(100),
minimum_height,
);
let window = Block::bordered().title(" New connection ");
let window = Block::bordered().title(if self.reconnect {
" Connect · password only "
} else {
" New database "
});
let inner = window.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(window, area);
@@ -201,8 +389,12 @@ impl Form {
])
.areas(inner);
frame.render_widget(
Paragraph::new(" dbdash │ New connection │ READ ONLY")
.style(Style::default().fg(Color::Cyan)),
Paragraph::new(if self.reconnect {
self.label()
} else {
"Configure a database · READ ONLY".into()
})
.style(Style::default().fg(Color::Cyan)),
header,
);
let [providers, details] =
@@ -234,7 +426,7 @@ impl Form {
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
self.path
.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);
frame.render_widget(Paragraph::new("Save an existing SQLite file path.\nFiles are never created or modified.\n\nSelect the saved database to connect.").wrap(Wrap { trim: false }), description);
} else {
let rows = Layout::vertical([
Constraint::Length(3),
@@ -254,26 +446,31 @@ impl Form {
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(rows[index])
});
for (index, input) in self.postgres.iter().enumerate() {
for (index, input) in
self.postgres
.iter()
.enumerate()
.take(if self.reconnect { 5 } else { 4 })
{
input.draw(
frame,
pairs[index / 2][index % 2],
labels[index],
index == 4,
self.focus == index + 1,
self.focus == index + 1 && (!self.reconnect || index == 4),
);
}
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 {
Style::default().fg(if !self.reconnect && self.focus == 5 {
Color::Cyan
} else {
Color::DarkGray
}),
),
),
pairs[2][1],
pairs[2][usize::from(self.reconnect)],
);
let tls_notice = match self.tls {
0 => "TLS required; certificates and hostnames are verified.",
@@ -283,32 +480,36 @@ impl Form {
frame.render_widget(
Paragraph::new(vec![
Line::raw(tls_notice),
Line::raw("Passwords are masked and never saved to disk."),
Line::raw("Passwords are requested on connect, never saved."),
])
.wrap(Wrap { trim: false }),
rows[3],
);
}
let message = if connecting {
"Connecting…"
let message = self.error.as_deref().unwrap_or(if self.reconnect {
"Enter a password if needed. The current database stays open until this succeeds."
} else {
self.error.as_deref().unwrap_or("Not connected to a new database. The current connection stays open until this succeeds.")
};
"Enter saves this configuration without connecting."
});
frame.render_widget(
Paragraph::new(message)
.wrap(Wrap { trim: false })
.style(Style::default().fg(if self.error.is_some() && !connecting {
.style(Style::default().fg(if self.error.is_some() {
Color::Red
} else {
Color::Cyan
})),
status,
);
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);
frame.render_widget(Paragraph::new(if self.reconnect {
" Enter connect · Ctrl-U clear password · Esc cancel · Ctrl-C quit"
} else {
" Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit"
}).style(Style::default().fg(Color::DarkGray)), help);
}
}
#[derive(Default)]
#[derive(Clone, Default)]
struct Input {
value: String,
cursor: usize,
@@ -439,11 +640,12 @@ mod tests {
fn password_never_appears_in_the_connection_screen() -> Result<()> {
let mut form = Form {
focus: 5,
reconnect: true,
..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))?;
terminal.draw(|frame| form.draw(frame))?;
let rendered: String = terminal
.backend()
.buffer()
@@ -456,6 +658,59 @@ mod tests {
Ok(())
}
#[test]
fn configurations_survive_restart_without_passwords() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("connections.sqlite3");
let mut profiles = Profiles::open(&path)?;
let mut postgres = Form {
tls: 2,
..Form::default()
};
postgres.postgres = [
"db.example",
"5440",
"warehouse",
"reader",
"do-not-store-this",
]
.map(Input::from);
profiles.save(&postgres)?;
let sqlite = Form {
provider: 1,
path: Input::from("./quoted '界'.sqlite"),
..Form::default()
};
profiles.save(&sqlite)?;
postgres.postgres[1] = Input::from("0");
assert!(profiles.save(&postgres).is_err());
drop(profiles);
let profiles = Profiles::open(&path)?;
assert_eq!(profiles.entries.len(), 2);
let restored = &profiles.entries[0];
assert_eq!(restored.provider, 0);
for index in 0..4 {
assert_eq!(
restored.postgres[index].value,
["db.example", "5440", "warehouse", "reader"][index]
);
}
assert_eq!(restored.tls, 2);
assert!(restored.postgres[4].value.is_empty());
assert_eq!(profiles.entries[1].provider, 1);
assert_eq!(
Path::new(&profiles.entries[1].path.value),
std::env::current_dir()?.join("./quoted '界'.sqlite")
);
assert!(
!std::fs::read(path)?
.windows(b"do-not-store-this".len())
.any(|bytes| bytes == b"do-not-store-this")
);
Ok(())
}
#[test]
fn invalid_ports_are_rejected_before_connecting() {
for port in ["0", "65536", "not-a-port"] {