Files
dbdash/src/connection.rs
T
2026-10-19 14:22:49 +02:00

953 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::path::{Path, PathBuf};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use postgres::{Config, config::SslMode};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Text},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::{
Result,
app::{App, Focus},
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 focus = Focus::Explorer;
let mut active = None;
let mut expanded = true;
let mut error: Option<String> = None;
let mut form = Form::default();
let mut show_form = false;
loop {
let entries = explorer_entries(
profiles.entries.len(),
active,
browser.as_ref().map(|app| app.relations.len()),
expanded,
);
terminal.draw(|frame| {
let navigation = Paragraph::new(if focus == Focus::Query && !show_form {
" Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
} else {
" Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
}).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
let help_height = navigation.line_count(frame.area().width) as u16;
let [main, help] =
Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area());
let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)])
.areas(main);
let items = entries.iter().map(|entry| {
ListItem::new(match *entry {
ExplorerEntry::Database(index) => {
let connected = browser.is_some() && index == active;
let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label());
format!("{} {label}", if connected && expanded { "▼" } else { "▶" })
}
ExplorerEntry::Relation(index) => {
let relation = &browser.as_ref().unwrap().relations[index];
format!(" ├ {} [{}]", relation.label, relation.kind)
}
})
});
let list = List::new(items)
.block(Block::bordered().title(" Explorer · c add ").border_style(
Style::default().fg(if focus == Focus::Explorer { 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, content, 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));
}
frame.render_widget(navigation, help);
if show_form {
form.draw(frame);
}
})?;
match event::read()? {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
if show_form {
match key.code {
KeyCode::Esc => {
form.clear_password();
show_form = false;
}
KeyCode::Enter if form.focus == 0 => form.focus = 1,
KeyCode::Enter => {
let result = if form.reconnect {
form.connect().and_then(App::new).map(|connected| {
browser = Some(connected);
active = selection.selected().and_then(|index| {
match entries[index] {
ExplorerEntry::Database(index) => index,
_ => None,
}
});
selection.select(active);
expanded = true;
focus = Focus::Explorer;
})
} else {
profiles.save(&form).map(|()| {
let updated = explorer_entries(
profiles.entries.len(),
active,
browser.as_ref().map(|app| app.relations.len()),
expanded,
);
selection.select(updated.iter().position(|entry| {
*entry
== ExplorerEntry::Database(Some(
profiles.entries.len() - 1,
))
}));
focus = Focus::Explorer;
})
};
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 {
match key.code {
KeyCode::Esc => focus = Focus::Explorer,
KeyCode::Tab | KeyCode::BackTab if browser.is_some() => {
focus = match (focus, key.code == KeyCode::BackTab) {
(Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query,
(Focus::Query, false) | (Focus::Explorer, true) => Focus::Rows,
_ => Focus::Explorer,
};
}
_ if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.handle_query_key(key);
}
}
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') => {
form = Form::default();
show_form = true;
}
KeyCode::Enter if focus == Focus::Explorer => {
if let Some(entry) =
selection.selected().and_then(|index| entries.get(index))
{
let index = match *entry {
ExplorerEntry::Relation(index) => {
browser.as_mut().unwrap().open_relation(Some(index));
focus = Focus::Rows;
continue;
}
ExplorerEntry::Database(index)
if browser.is_some() && index == active =>
{
expanded = !expanded;
continue;
}
ExplorerEntry::Database(Some(index)) => index,
ExplorerEntry::Database(None) => continue,
};
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);
selection.select(Some(index));
expanded = true;
focus = Focus::Explorer;
error = None;
}
Err(failure) => error = Some(error_text(failure.as_ref())),
}
}
}
}
KeyCode::Left | KeyCode::Char('h') if focus == Focus::Explorer => {
if matches!(
selection.selected().and_then(|index| entries.get(index)),
Some(ExplorerEntry::Relation(_))
) || selection.selected().and_then(|index| entries.get(index))
== Some(&ExplorerEntry::Database(active))
{
expanded = false;
selection.select(
entries.iter().position(|entry| {
*entry == ExplorerEntry::Database(active)
}),
);
}
}
KeyCode::Right | KeyCode::Char('l') if focus == Focus::Explorer => {
if selection.selected().and_then(|index| entries.get(index))
== Some(&ExplorerEntry::Database(active))
{
expanded = true;
}
}
KeyCode::Char('r') if focus == Focus::Explorer => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
}
KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
selection.select_next()
}
KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
selection.select_previous()
}
KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
selection.select_first()
}
KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
selection.select_last()
}
_ if focus == Focus::Rows => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
}
_ => {}
}
}
}
Event::Paste(text) if show_form => {
form.error = None;
if let Some(input) = form.input_mut() {
input.paste(&text);
}
}
Event::Paste(text) if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.paste_query(&text);
}
}
_ => {}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExplorerEntry {
Database(Option<usize>),
Relation(usize),
}
fn explorer_entries(
count: usize,
active: Option<usize>,
relations: Option<usize>,
expanded: bool,
) -> Vec<ExplorerEntry> {
let mut entries = Vec::new();
let session = active.is_none() && relations.is_some();
for database in session
.then_some(None)
.into_iter()
.chain((0..count).map(Some))
{
entries.push(ExplorerEntry::Database(database));
if database == active && expanded {
entries.extend((0..relations.unwrap_or(0)).map(ExplorerEntry::Relation));
}
}
entries
}
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,
postgres: [Input; 5],
tls: usize,
path: Input,
error: Option<String>,
reconnect: bool,
}
impl Default for Form {
fn default() -> Self {
Self {
provider: 0,
focus: 0,
tls: 0,
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
path: Input::default(),
error: None,
reconnect: false,
}
}
}
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());
}
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());
}
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);
}
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)?)
.filter(|_| self.focus < 5)
}
}
fn clear_password(&mut self) {
self.postgres[4] = Input::default();
}
fn edit(&mut self, key: KeyEvent) {
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 == 5 => {
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 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 {
frame.render_widget(Clear, screen);
frame.render_widget(
Paragraph::new(format!(
"Enlarge terminal to at least 74×{minimum_height}. Ctrl-C to quit."
)),
screen,
);
return;
}
let area = Rect::new(
screen.x + (screen.width - screen.width.saturating_sub(4).min(100)) / 2,
screen.y + (screen.height - minimum_height) / 2,
screen.width.saturating_sub(4).min(100),
minimum_height,
);
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);
let [header, body, status, help] = Layout::vertical([
Constraint::Length(2),
Constraint::Min(4),
Constraint::Length(3),
Constraint::Length(2),
])
.areas(inner);
frame.render_widget(
Paragraph::new(if self.reconnect {
self.label()
} else {
"Configure a database · READ ONLY".into()
})
.style(Style::default().fg(Color::Cyan)),
header,
);
let [providers, details] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
frame.render_widget(
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,
self.focus == 1,
None,
);
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),
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()
.take(if self.reconnect { 5 } else { 4 })
{
input.draw(
frame,
pairs[index / 2][index % 2],
labels[index],
index == 4,
self.focus == index + 1 && (!self.reconnect || index == 4),
None,
);
}
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.reconnect && self.focus == 5 {
Color::Cyan
} else {
Color::DarkGray
}),
),
),
pairs[2][usize::from(self.reconnect)],
);
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 requested on connect, never saved."),
])
.wrap(Wrap { trim: false }),
rows[3],
);
}
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 {
"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() {
Color::Red
} else {
Color::Cyan
})),
status,
);
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(Clone, Default)]
pub(crate) struct Input {
pub(crate) value: String,
cursor: usize,
}
impl From<&str> for Input {
fn from(value: &str) -> Self {
Self {
value: value.into(),
cursor: value.len(),
}
}
}
impl Input {
pub(crate) fn edit(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Left => {
self.cursor = self.value[..self.cursor]
.char_indices()
.next_back()
.map_or(0, |(index, _)| index)
}
KeyCode::Right => {
self.cursor += self.value[self.cursor..]
.chars()
.next()
.map_or(0, char::len_utf8)
}
KeyCode::Up | KeyCode::Down => {
let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
let column = self.value[start..self.cursor].chars().count();
let target = if key.code == KeyCode::Up {
start
.checked_sub(1)
.map(|end| (self.value[..end].rfind('\n').map_or(0, |i| i + 1), end))
} else {
self.value[self.cursor..].find('\n').map(|i| {
let start = self.cursor + i + 1;
let end = self.value[start..]
.find('\n')
.map_or(self.value.len(), |i| start + i);
(start, end)
})
};
if let Some((start, end)) = target {
self.cursor = start
+ self.value[start..end]
.chars()
.take(column)
.map(char::len_utf8)
.sum::<usize>();
}
}
KeyCode::Home => {
self.cursor = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
}
KeyCode::End => {
self.cursor += self.value[self.cursor..]
.find('\n')
.unwrap_or(self.value.len() - self.cursor);
}
KeyCode::Backspace if self.cursor > 0 => {
let previous = self.value[..self.cursor]
.char_indices()
.next_back()
.unwrap()
.0;
self.value.drain(previous..self.cursor);
self.cursor = previous;
}
KeyCode::Delete if self.cursor < self.value.len() => {
self.value.remove(self.cursor);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.value.clear();
self.cursor = 0;
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& !character.is_control() =>
{
self.value.insert(self.cursor, character);
self.cursor += character.len_utf8();
}
_ => {}
}
}
fn paste(&mut self, value: &str) {
let text: String = value
.chars()
.filter(|character| !character.is_control())
.collect();
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
pub(crate) fn paste_multiline(&mut self, value: &str) {
let text: String = value
.chars()
.map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control())
.collect();
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
area: Rect,
label: &str,
secret: bool,
focused: bool,
highlighted: Option<Text<'_>>,
) {
let text = if secret {
Text::raw("•".repeat(self.value.chars().count()))
} else {
highlighted.unwrap_or_else(|| Text::raw(&self.value))
};
let prefix = &self.value[..self.cursor];
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
let row_offset = row.saturating_sub(area.height.saturating_sub(3));
let before_cursor = if secret {
self.value[..self.cursor].chars().count()
} else {
Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width()
};
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(area.width.saturating_sub(3));
frame.render_widget(
Paragraph::new(text).scroll((row_offset, offset)).block(
Block::bordered()
.title(label)
.border_style(Style::default().fg(if focused {
Color::Cyan
} else {
Color::DarkGray
})),
),
area,
);
if focused && area.width > 2 && area.height > 2 {
frame
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explorer_keeps_relations_under_the_connected_database() {
use ExplorerEntry::{Database as Db, Relation as Rel};
assert_eq!(
explorer_entries(3, Some(1), Some(2), true),
vec![Db(Some(0)), Db(Some(1)), Rel(0), Rel(1), Db(Some(2))]
);
assert_eq!(
explorer_entries(3, Some(1), Some(2), false),
vec![Db(Some(0)), Db(Some(1)), Db(Some(2))]
);
assert_eq!(
explorer_entries(1, None, Some(1), true),
vec![Db(None), Rel(0), Db(Some(0))]
);
assert_eq!(
explorer_entries(1, Some(0), Some(0), true),
vec![Db(Some(0))]
);
assert_eq!(explorer_entries(1, None, None, true), vec![Db(Some(0))]);
}
#[test]
fn editing_preserves_unicode_and_filters_terminal_controls() {
let mut input = Input::default();
input.paste("aé🙂z\n\u{1b}");
input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.value, "aéz");
input.edit(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(input.value, "aé界");
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
input.paste("/tmp/");
assert_eq!(input.value, "/tmp/aé界");
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(input.cursor, input.value.len());
input.edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(input.value.is_empty());
assert_eq!(input.cursor, 0);
}
#[test]
fn multiline_editing_preserves_lines_and_unicode() {
let mut input = Input::default();
input.paste_multiline("aé界\nxy\nz\r\n\t\u{1b}");
assert_eq!(input.value, "aé界\nxy\nz\n ");
for code in [KeyCode::Up, KeyCode::Up, KeyCode::Up] {
input.edit(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(&input.value[..input.cursor], "a");
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(&input.value[..input.cursor], "aé界\nxy");
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.value, "aé界xy\nz\n ");
assert_eq!(input.cursor, "aé界".len());
}
#[test]
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))?;
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(!rendered.contains("test-secret-é"));
assert!(rendered.contains("•••••••••••••"));
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"] {
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)
);
}
}
}