feat: persist configured databases and connect from sidebar
This commit is contained in:
+9
-18
@@ -1,7 +1,7 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Line,
|
text::Line,
|
||||||
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
|
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
|
||||||
@@ -24,7 +24,7 @@ pub struct App {
|
|||||||
rows_state: TableState,
|
rows_state: TableState,
|
||||||
schema_state: TableState,
|
schema_state: TableState,
|
||||||
column_offset: usize,
|
column_offset: usize,
|
||||||
sidebar_focus: bool,
|
pub(crate) sidebar_focus: bool,
|
||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -55,19 +55,12 @@ impl App {
|
|||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool {
|
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||||
if key.code == KeyCode::Char('q')
|
|
||||||
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Tab | KeyCode::BackTab => self.sidebar_focus = !self.sidebar_focus,
|
|
||||||
KeyCode::Enter if self.sidebar_focus => {
|
KeyCode::Enter if self.sidebar_focus => {
|
||||||
self.open_selection();
|
self.open_selection();
|
||||||
self.sidebar_focus = false;
|
self.sidebar_focus = false;
|
||||||
}
|
}
|
||||||
KeyCode::Esc => self.sidebar_focus = true,
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
||||||
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
||||||
@@ -98,7 +91,6 @@ impl App {
|
|||||||
KeyCode::Char('r') => self.refresh(),
|
KeyCode::Char('r') => self.refresh(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_selection(&mut self, delta: isize) {
|
fn move_selection(&mut self, delta: isize) {
|
||||||
@@ -171,9 +163,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn draw(&mut self, frame: &mut Frame) {
|
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
|
||||||
let area = frame.area();
|
if area.width < 46 || area.height < 12 {
|
||||||
if area.width < 70 || area.height < 12 {
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
||||||
area,
|
area,
|
||||||
@@ -199,7 +190,7 @@ impl App {
|
|||||||
let [sidebar, content] =
|
let [sidebar, content] =
|
||||||
Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body);
|
Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body);
|
||||||
let sidebar_title = format!(" Relations ({}) ", self.relations.len());
|
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() {
|
if self.relations.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block),
|
Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block),
|
||||||
@@ -221,7 +212,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
" Rows · s for schema "
|
" 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 {
|
let count = if self.show_schema {
|
||||||
5
|
5
|
||||||
} else {
|
} else {
|
||||||
@@ -348,7 +339,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(
|
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);
|
).style(Style::default().fg(Color::DarkGray)), help);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-48
@@ -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 crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
use postgres::{Config, config::SslMode};
|
use postgres::{Config, config::SslMode};
|
||||||
@@ -7,22 +10,43 @@ use ratatui::{
|
|||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::Line,
|
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};
|
use crate::{Result, app::App, db::Database, error_text};
|
||||||
|
|
||||||
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
|
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 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 form = Form::default();
|
||||||
let mut show_form = browser.is_none();
|
let mut show_form = false;
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| {
|
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 {
|
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 {
|
if show_form {
|
||||||
form.draw(frame, false);
|
form.draw(frame);
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
@@ -33,40 +57,94 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
if show_form {
|
if show_form {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
if browser.is_none() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
form.clear_password();
|
form.clear_password();
|
||||||
show_form = false;
|
show_form = false;
|
||||||
}
|
}
|
||||||
KeyCode::Enter if form.focus == 0 => form.focus = 1,
|
KeyCode::Enter if form.focus == 0 => form.focus = 1,
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
terminal.draw(|frame| {
|
let result = if form.reconnect {
|
||||||
if let Some(browser) = &mut browser {
|
form.connect().and_then(App::new).map(|connected| {
|
||||||
browser.draw(frame);
|
|
||||||
}
|
|
||||||
form.draw(frame, true);
|
|
||||||
})?;
|
|
||||||
match form.connect().and_then(App::new) {
|
|
||||||
Ok(connected) => {
|
|
||||||
form.clear_password();
|
|
||||||
browser = Some(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;
|
show_form = false;
|
||||||
|
error = None;
|
||||||
}
|
}
|
||||||
Err(error) => form.error = Some(error_text(error.as_ref())),
|
Err(error) => form.error = Some(error_text(error.as_ref())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => form.edit(key),
|
_ => form.edit(key),
|
||||||
}
|
}
|
||||||
} else if key.code == KeyCode::Char('c') {
|
} else {
|
||||||
form.error = None;
|
match key.code {
|
||||||
form.focus = 0;
|
KeyCode::Char('q') => return Ok(()),
|
||||||
show_form = true;
|
KeyCode::Char('c') => {
|
||||||
} else if browser
|
form = Form::default();
|
||||||
.as_mut()
|
show_form = true;
|
||||||
.is_some_and(|browser| browser.handle_key(key))
|
}
|
||||||
{
|
KeyCode::Esc => databases_focus = true,
|
||||||
return Ok(());
|
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 => {
|
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 {
|
struct Form {
|
||||||
provider: usize,
|
provider: usize,
|
||||||
focus: usize,
|
focus: usize,
|
||||||
@@ -87,6 +234,7 @@ struct Form {
|
|||||||
tls: usize,
|
tls: usize,
|
||||||
path: Input,
|
path: Input,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
reconnect: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Form {
|
impl Default for Form {
|
||||||
@@ -98,6 +246,7 @@ impl Default for Form {
|
|||||||
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
|
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
|
||||||
path: Input::default(),
|
path: Input::default(),
|
||||||
error: None,
|
error: None,
|
||||||
|
reconnect: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +259,10 @@ impl Form {
|
|||||||
}
|
}
|
||||||
return Database::open_sqlite(Path::new(&self.path.value));
|
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;
|
let [host, port, database, user, password] = &self.postgres;
|
||||||
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
|
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
|
||||||
return Err("PostgreSQL requires a host, database, and user.".into());
|
return Err("PostgreSQL requires a host, database, and user.".into());
|
||||||
@@ -135,14 +288,19 @@ impl Form {
|
|||||||
if !password.value.is_empty() {
|
if !password.value.is_empty() {
|
||||||
config.password(&password.value);
|
config.password(&password.value);
|
||||||
}
|
}
|
||||||
Database::connect_postgres(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_mut(&mut self) -> Option<&mut Input> {
|
fn input_mut(&mut self) -> Option<&mut Input> {
|
||||||
|
if self.reconnect {
|
||||||
|
return Some(&mut self.postgres[4]);
|
||||||
|
}
|
||||||
if self.provider == 1 {
|
if self.provider == 1 {
|
||||||
(self.focus == 1).then_some(&mut self.path)
|
(self.focus == 1).then_some(&mut self.path)
|
||||||
} else {
|
} 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) {
|
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 {
|
match key.code {
|
||||||
KeyCode::Tab => self.focus = (self.focus + 1) % count,
|
KeyCode::Tab => self.focus = (self.focus + 1) % count,
|
||||||
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
|
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
|
||||||
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => {
|
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => {
|
||||||
self.provider = 1 - self.provider
|
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;
|
self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -170,7 +333,28 @@ impl Form {
|
|||||||
self.error = None;
|
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 screen = frame.area();
|
||||||
let minimum_height = if self.provider == 0 { 20 } else { 16 };
|
let minimum_height = if self.provider == 0 { 20 } else { 16 };
|
||||||
if screen.width < 74 || screen.height < minimum_height {
|
if screen.width < 74 || screen.height < minimum_height {
|
||||||
@@ -189,7 +373,11 @@ impl Form {
|
|||||||
screen.width.saturating_sub(4).min(100),
|
screen.width.saturating_sub(4).min(100),
|
||||||
minimum_height,
|
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);
|
let inner = window.inner(area);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(window, area);
|
frame.render_widget(window, area);
|
||||||
@@ -201,8 +389,12 @@ impl Form {
|
|||||||
])
|
])
|
||||||
.areas(inner);
|
.areas(inner);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(" dbdash │ New connection │ READ ONLY")
|
Paragraph::new(if self.reconnect {
|
||||||
.style(Style::default().fg(Color::Cyan)),
|
self.label()
|
||||||
|
} else {
|
||||||
|
"Configure a database · READ ONLY".into()
|
||||||
|
})
|
||||||
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
header,
|
header,
|
||||||
);
|
);
|
||||||
let [providers, details] =
|
let [providers, details] =
|
||||||
@@ -234,7 +426,7 @@ impl Form {
|
|||||||
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
||||||
self.path
|
self.path
|
||||||
.draw(frame, path, " Database file path ", false, self.focus == 1);
|
.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 {
|
} else {
|
||||||
let rows = Layout::vertical([
|
let rows = Layout::vertical([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
@@ -254,26 +446,31 @@ impl Form {
|
|||||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.areas(rows[index])
|
.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(
|
input.draw(
|
||||||
frame,
|
frame,
|
||||||
pairs[index / 2][index % 2],
|
pairs[index / 2][index % 2],
|
||||||
labels[index],
|
labels[index],
|
||||||
index == 4,
|
index == 4,
|
||||||
self.focus == index + 1,
|
self.focus == index + 1 && (!self.reconnect || index == 4),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block(
|
Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block(
|
||||||
Block::bordered().title(" TLS · ←/→ change ").border_style(
|
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
|
Color::Cyan
|
||||||
} else {
|
} else {
|
||||||
Color::DarkGray
|
Color::DarkGray
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
pairs[2][1],
|
pairs[2][usize::from(self.reconnect)],
|
||||||
);
|
);
|
||||||
let tls_notice = match self.tls {
|
let tls_notice = match self.tls {
|
||||||
0 => "TLS required; certificates and hostnames are verified.",
|
0 => "TLS required; certificates and hostnames are verified.",
|
||||||
@@ -283,32 +480,36 @@ impl Form {
|
|||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(vec![
|
Paragraph::new(vec![
|
||||||
Line::raw(tls_notice),
|
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 }),
|
.wrap(Wrap { trim: false }),
|
||||||
rows[3],
|
rows[3],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let message = if connecting {
|
let message = self.error.as_deref().unwrap_or(if self.reconnect {
|
||||||
"Connecting…"
|
"Enter a password if needed. The current database stays open until this succeeds."
|
||||||
} else {
|
} 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(
|
frame.render_widget(
|
||||||
Paragraph::new(message)
|
Paragraph::new(message)
|
||||||
.wrap(Wrap { trim: false })
|
.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
|
Color::Red
|
||||||
} else {
|
} else {
|
||||||
Color::Cyan
|
Color::Cyan
|
||||||
})),
|
})),
|
||||||
status,
|
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 {
|
struct Input {
|
||||||
value: String,
|
value: String,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
@@ -439,11 +640,12 @@ mod tests {
|
|||||||
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
||||||
let mut form = Form {
|
let mut form = Form {
|
||||||
focus: 5,
|
focus: 5,
|
||||||
|
reconnect: true,
|
||||||
..Form::default()
|
..Form::default()
|
||||||
};
|
};
|
||||||
form.postgres[4] = Input::from("test-secret-é");
|
form.postgres[4] = Input::from("test-secret-é");
|
||||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?;
|
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
|
let rendered: String = terminal
|
||||||
.backend()
|
.backend()
|
||||||
.buffer()
|
.buffer()
|
||||||
@@ -456,6 +658,59 @@ mod tests {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn invalid_ports_are_rejected_before_connecting() {
|
fn invalid_ports_are_rejected_before_connecting() {
|
||||||
for port in ["0", "65536", "not-a-port"] {
|
for port in ["0", "65536", "not-a-port"] {
|
||||||
|
|||||||
+13
-8
@@ -13,7 +13,7 @@ Usage:
|
|||||||
dbdash --sqlite PATH
|
dbdash --sqlite PATH
|
||||||
dbdash --help
|
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
|
cargo run
|
||||||
Use the in-app password field to keep credentials out of process arguments.
|
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.
|
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 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:
|
Navigation:
|
||||||
Tab / Esc Switch focus / return to relations
|
Tab/Shift-Tab Cycle focus: databases, relations, rows (Esc returns to databases)
|
||||||
Up/Down, j/k Move selection (Enter opens a relation)
|
Up/Down, j/k Move selection (Enter connects to a database or opens a relation)
|
||||||
Home/End, g/G First / last item
|
Home/End, g/G First / last item
|
||||||
s Toggle rows and column schema
|
s Toggle rows and column schema
|
||||||
Left/Right Scroll columns (h/l also work)
|
Left/Right Scroll columns (h/l also work)
|
||||||
n/p, PgDn/Up Next / previous 100-row page
|
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
|
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.
|
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.
|
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.
|
Enter saves without connecting, including when the database is offline.
|
||||||
Form details are session-only. Passwords clear after connecting or cancelling.
|
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.
|
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.
|
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