diff --git a/src/app.rs b/src/app.rs index 612ada3..b56b3ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState}, @@ -24,7 +24,7 @@ pub struct App { rows_state: TableState, schema_state: TableState, column_offset: usize, - sidebar_focus: bool, + pub(crate) sidebar_focus: bool, show_schema: bool, error: Option, } @@ -55,19 +55,12 @@ impl App { Ok(app) } - pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool { - if key.code == KeyCode::Char('q') - || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) - { - return true; - } + pub(crate) fn handle_key(&mut self, key: KeyEvent) { match key.code { - KeyCode::Tab | KeyCode::BackTab => self.sidebar_focus = !self.sidebar_focus, KeyCode::Enter if self.sidebar_focus => { self.open_selection(); self.sidebar_focus = false; } - KeyCode::Esc => self.sidebar_focus = true, KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), @@ -98,7 +91,6 @@ impl App { KeyCode::Char('r') => self.refresh(), _ => {} } - false } fn move_selection(&mut self, delta: isize) { @@ -171,9 +163,8 @@ impl App { } } - pub(crate) fn draw(&mut self, frame: &mut Frame) { - let area = frame.area(); - if area.width < 70 || area.height < 12 { + pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) { + if area.width < 46 || area.height < 12 { frame.render_widget( Paragraph::new("Enlarge terminal to at least 70×12. q to quit."), area, @@ -199,7 +190,7 @@ impl App { let [sidebar, content] = Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body); let sidebar_title = format!(" Relations ({}) ", self.relations.len()); - let sidebar_block = panel(&sidebar_title, self.sidebar_focus); + let sidebar_block = panel(&sidebar_title, focused && self.sidebar_focus); if self.relations.is_empty() { frame.render_widget( Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block), @@ -221,7 +212,7 @@ impl App { } else { " Rows · s for schema " }; - let content_block = panel(title, !self.sidebar_focus); + let content_block = panel(title, focused && !self.sidebar_focus); let count = if self.show_schema { 5 } else { @@ -348,7 +339,7 @@ impl App { ); } frame.render_widget(Paragraph::new( - " Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c connect · q quit" + " Tab focus · Esc databases · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c add database · q quit" ).style(Style::default().fg(Color::DarkGray)), help); } } diff --git a/src/connection.rs b/src/connection.rs index cc5fd05..796fd2d 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -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) -> 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 = 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) -> 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) -> Result< } } +fn config_path() -> Result { + 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
, +} + +impl Profiles { + fn open(path: &Path) -> Result { + 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::>>()?; + 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, + 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 { 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"] { diff --git a/src/main.rs b/src/main.rs index df27f74..d6b2f99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ Usage: dbdash --sqlite PATH dbdash --help -Run without a connection argument to open the in-app connection screen: +Run without a connection argument to open the saved database list: cargo run Use the in-app password field to keep credentials out of process arguments. @@ -26,23 +26,28 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them. 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 inspector needs 70×12 terminal cells; the PostgreSQL form needs 70×16. +The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20. Navigation: - Tab / Esc Switch focus / return to relations - Up/Down, j/k Move selection (Enter opens a relation) + Tab/Shift-Tab Cycle focus: databases, relations, rows (Esc returns to databases) + Up/Down, j/k Move selection (Enter connects to a database or opens a relation) Home/End, g/G First / last item s Toggle rows and column schema Left/Right Scroll columns (h/l also work) n/p, PgDn/Up Next / previous 100-row page - c Open the connection screen (Esc keeps the current database) + c Add a configured database in a floating window r Refresh relations and the active relation q / Ctrl-C Quit - Connection screen: Up/Down chooses a provider; Enter starts editing. + New database window: 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. + Enter saves without connecting, including when the database is offline. + Select a saved database and press Enter to connect. PostgreSQL prompts for a + password; Esc cancels. Passwords are masked, never saved, and clear on success + or cancellation. Ctrl-U clears a field; pasting is supported. Enter the database user explicitly; the operating-system username is not used. + Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling + back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute. + CLI connections are not saved. Failed switches keep the current database open. 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.