953 lines
38 KiB
Rust
953 lines
38 KiB
Rust
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)
|
||
);
|
||
}
|
||
}
|
||
}
|