use crate::tunnels::Tunnel;
use ratatui::prelude::*;
use ratatui::widgets::{Clear, Paragraph, Wrap};
use super::*;
pub(crate) struct Field {
pub(crate) label: String,
pub(crate) default: String,
pub(crate) value: String,
pub(crate) kind: Kind,
pub(crate) choice: usize,
}
pub(crate) enum Kind {
Text,
Choice(Vec<String>),
Secret,
}
impl Field {
pub(super) fn new(label: &str, default: &str) -> Self {
Self {
label: label.into(),
default: default.into(),
value: String::new(),
kind: Kind::Text,
choice: 0,
}
}
pub(super) fn filled(label: &str, value: &str) -> Self {
Self {
value: value.into(),
..Self::new(label, "")
}
}
pub(super) fn secret(label: &str) -> Self {
Self {
kind: Kind::Secret,
..Self::new(label, "")
}
}
pub(super) fn choice(label: &str, options: &[&str], at: usize) -> Self {
let options: Vec<String> = options.iter().map(|o| o.to_string()).collect();
Self {
choice: at.min(options.len().saturating_sub(1)),
kind: Kind::Choice(options),
..Self::new(label, "")
}
}
pub(super) fn display(&self) -> String {
match &self.kind {
Kind::Text => self.value.clone(),
Kind::Choice(options) => format!("‹ {} ›", options[self.choice]),
Kind::Secret => "•".repeat(self.value.chars().count()),
}
}
pub(super) fn is_choice(&self) -> bool {
matches!(self.kind, Kind::Choice(_))
}
}
#[derive(Clone)]
pub(crate) enum Action {
AddConn {
engine: Engine,
},
EditConn {
engine: Engine,
original: String,
},
SetPassword {
engine: Engine,
name: String,
},
Snippet {
original: Option<String>,
},
EditPassword {
idx: usize,
},
Forward {
key: String,
},
EditSetting {
key: String,
label: String,
},
}
pub(crate) struct Prompt {
pub(crate) title: String,
pub(crate) fields: Vec<Field>,
pub(crate) idx: usize,
pub(crate) action: Action,
}
pub(super) const SSLMODES: [&str; 6] = [
"(unset)",
"prefer",
"require",
"verify-ca",
"verify-full",
"disable",
];
pub(super) const TRUST_CERT: [&str; 2] = [
"validate the certificate",
"trust it (-C, for a self-signed server)",
];
fn conn_fields(engine: Engine, from: Option<&Conn>) -> Vec<Field> {
let get = |pick: fn(&Conn) -> &String| from.map(pick).cloned().unwrap_or_default();
if engine == Engine::Sqlite {
return vec![
Field::filled("Name (what you type after esql)", &get(|c| &c.name)),
Field::filled("Database file", &get(|c| &c.database)),
];
}
let port = Field {
default: engine.default_port().to_string(),
..Field::filled("Port", &get(|c| &c.port))
};
let mut fields = vec![
Field::filled("Name (what you type after esql)", &get(|c| &c.name)),
Field::filled("Host (IP or DNS name)", &get(|c| &c.host)),
port,
Field::filled("Database", &get(|c| &c.database)),
Field::filled("User", &get(|c| &c.user)),
];
let extra = |key: &str| {
from.and_then(|c| c.extra.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)))
.map(|(_, v)| v.as_str())
.unwrap_or("")
};
match engine {
Engine::Pg => {
let at = SSLMODES
.iter()
.position(|m| *m == extra("sslmode"))
.unwrap_or(0);
fields.push(Field::choice("Encryption (sslmode)", &SSLMODES, at));
}
Engine::MsSql => {
let at = usize::from(extra("trust_cert") == "yes");
fields.push(Field::choice("Certificate", &TRUST_CERT, at));
}
_ => {}
}
fields
}
impl Prompt {
pub(super) fn cur_mut(&mut self) -> &mut Field {
&mut self.fields[self.idx]
}
pub(super) fn add_conn(engine: Engine) -> Self {
Self {
title: format!(
"Add a {} connection to {}",
engine.label(),
crate::ini::collapse_tilde(&engine.store().to_string_lossy())
),
idx: 0,
action: Action::AddConn { engine },
fields: conn_fields(engine, None),
}
}
pub(super) fn edit_conn(c: &Conn) -> Self {
Self {
title: format!("edit {} connection '{}'", c.engine.label(), c.name),
idx: 0,
action: Action::EditConn {
engine: c.engine,
original: c.name.clone(),
},
fields: conn_fields(c.engine, Some(c)),
}
}
pub(super) fn password(c: &Conn) -> Self {
let fields = match c.engine {
Engine::Pg => vec![
Field::filled(
"Host (* matches any)",
if c.host.is_empty() {
"localhost"
} else {
&c.host
},
),
Field::filled("Port (* matches any)", &c.port_or_default()),
Field::filled("Database (* = every database on this server)", "*"),
Field::filled("User (* matches any)", &c.user),
Field::secret("Password (never shown, never in an argv)"),
],
_ => vec![Field::secret("Password (never shown, never in an argv)")],
};
Self {
title: format!("save the password for '{}'", c.name),
idx: 0,
action: Action::SetPassword {
engine: c.engine,
name: c.name.clone(),
},
fields,
}
}
pub(super) fn forward(c: &Conn, s: &Settings) -> Self {
let via = match s.tunnel_host.is_empty() {
true => crate::vias::default_host(&c.key()).unwrap_or_default(),
false => s.tunnel_host.clone(),
};
let db_host = forward_target(&via, &c.host);
Self {
title: format!("reach {} through an ssh host (ssh -L)", c.name),
idx: 0,
action: Action::Forward { key: c.key() },
fields: vec![
Field::filled("Tunnel through (ssh host, Ctrl-o to pick)", &via),
Field::filled("Database host, as that machine sees it", &db_host),
Field::filled("Database port", &c.port_or_default()),
Field::new("Local port (where you'll reach it)", "= database port"),
],
}
}
}
pub(super) fn forward_target(via: &str, db_host: &str) -> String {
if db_host.is_empty() {
return "localhost".to_string();
}
if crate::sshhosts::is_same_machine(via, db_host) {
return "127.0.0.1".to_string();
}
db_host.to_string()
}
impl Prompt {
pub(super) fn edit_password(cred: &crate::creds::Cred, idx: usize) -> Self {
Self {
title: format!("which connection is {}'s password for?", cred.user),
idx: 0,
action: Action::EditPassword { idx },
fields: vec![
Field::filled("Host (* matches any)", &cred.host),
Field::filled("Port (* matches any)", &cred.port),
Field::filled(
"Database (* = every database on this server)",
&cred.database,
),
Field::filled("User (* matches any)", &cred.user),
],
}
}
pub(super) fn snippet(from: Option<&crate::snippets::Snippet>) -> Self {
Self {
title: match from {
Some(s) => format!("Edit the snippet '{}'", s.name),
None => "New saved query".to_string(),
},
idx: 0,
action: Action::Snippet {
original: from.map(|s| s.name.clone()),
},
fields: vec![
Field::filled(
"Name (what you type after the connection, as :name)",
from.map(|s| s.name.as_str()).unwrap_or(""),
),
Field::filled(
"SQL (o opens it in $EDITOR afterwards, for anything longer)",
&from.map(|s| s.summary()).unwrap_or_default(),
),
],
}
}
pub(super) fn edit_setting(row: &settings::Row) -> Self {
let mut field = Field::filled(row.help, &row.value);
field.default = row.default.clone();
Self {
title: format!("setting: {}", row.label),
idx: 0,
action: Action::EditSetting {
key: row.key.to_string(),
label: row.label.to_string(),
},
fields: vec![field],
}
}
pub(super) fn step(&self, dir: isize) -> usize {
let len = self.fields.len() as isize;
(self.idx as isize + dir).rem_euclid(len) as usize
}
pub(super) fn on_last_field(&self) -> bool {
self.idx + 1 == self.fields.len()
}
pub(super) fn resolves_to(&self) -> Option<(String, String)> {
let v = |i: usize| self.fields[i].value.trim();
let (Action::AddConn { engine } | Action::EditConn { engine, .. }) = &self.action else {
return None;
};
let (host, port, db, user) = match engine {
Engine::Sqlite => return None,
_ => (v(1), v(2), v(3), v(4)),
};
if host.is_empty() {
return None;
}
let port = if port.is_empty() {
engine.default_port()
} else {
port
};
let mut out = String::new();
if !user.is_empty() {
out.push_str(user);
out.push('@');
}
out.push_str(host);
out.push(':');
out.push_str(port);
if !db.is_empty() {
out.push('/');
out.push_str(db);
}
Some((out, port.to_string()))
}
pub(super) fn command_preview(&self) -> Option<String> {
let v = |i: usize| self.fields[i].value.trim();
match &self.action {
Action::AddConn { engine } | Action::EditConn { engine, .. } => {
let name = v(0);
if name.is_empty() {
return None;
}
Some(match engine {
Engine::Pg => format!("esql {name} → psql \"service={name}\""),
Engine::MySql => {
format!("esql {name} → mysql --defaults-group-suffix={name}")
}
Engine::Sqlite => format!("esql {name} → sqlite3 {}", v(1)),
Engine::MsSql => {
let port = if v(2).is_empty() { "1433" } else { v(2) };
let host = if v(1).is_empty() { "localhost" } else { v(1) };
format!(
"esql {name} → sqlcmd -S {host},{port} -d {} -U {}",
v(3),
v(4)
)
}
})
}
Action::Snippet { .. } => {
let name = v(0);
(!name.is_empty()).then(|| format!("esql <connection> :{name}"))
}
Action::EditPassword { .. } => Some(format!(
"~/.pgpass {}:{}:{}:{}:•••• (kept)",
v(0),
v(1),
v(2),
v(3)
)),
Action::SetPassword { engine, name } => Some(match engine {
Engine::Pg => format!("~/.pgpass {}:{}:{}:{}:••••", v(0), v(1), v(2), v(3)),
_ => format!("~/.my.cnf [client{name}] password=••••"),
}),
Action::Forward { .. } => {
let via = v(0);
let db_host = v(1);
let db_port = v(2);
let local = if v(3).is_empty() { db_port } else { v(3) };
if via.is_empty() {
return None;
}
Some(format!("ssh -N -L {local}:{db_host}:{db_port} {via}"))
}
Action::EditSetting { .. } => None,
}
}
}
pub(super) fn render_prompt(f: &mut Frame, area: Rect, p: &Prompt, tunnels: &[Tunnel]) {
let mut lines: Vec<Line> = Vec::new();
let mut texts: Vec<String> = Vec::new();
for (i, field) in p.fields.iter().enumerate() {
let active = i == p.idx;
let head = if field.default.is_empty() {
format!("{}: ", field.label)
} else {
format!("{} [{}]: ", field.label, field.default)
};
let label_style = if active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::DIM)
};
let (value_style, tail) = if field.is_choice() {
let style = if active {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
(style, if active { " h/l or ←/→" } else { "" })
} else {
(Style::default(), if active { "█" } else { "" })
};
texts.push(format!(
"{}{}{}{}",
if active { "▸ " } else { " " },
head,
field.display(),
tail
));
lines.push(Line::from(vec![
Span::raw(if active { "▸ " } else { " " }),
Span::styled(head, label_style),
Span::styled(field.display(), value_style),
Span::styled(
tail.to_string(),
Style::default().add_modifier(Modifier::DIM),
),
]));
}
if let Some(cmd) = p.command_preview() {
lines.push(Line::raw(""));
texts.push(String::new());
texts.push(format!(" runs {cmd}"));
lines.push(Line::from(vec![
Span::styled(" runs ", Style::default().add_modifier(Modifier::DIM)),
Span::styled(cmd, Style::default().fg(Color::Green)),
]));
}
if let Some((target, port)) = p.resolves_to() {
let via = tunnels.iter().find_map(|t| {
let (open, _, _) = t.ports()?;
(t.kind == 'L' && open == port).then(|| t.host.clone())
});
let line = match via {
Some(host) => format!(" {target} · through the tunnel to {host}"),
None => format!(" {target}"),
};
texts.push(line.clone());
lines.push(Line::from(Span::styled(
line,
Style::default().add_modifier(Modifier::DIM),
)));
}
let hint = "Enter next/submit · Ctrl-j/k · Ctrl-↑↓ · Tab move field · Esc cancel";
lines.push(Line::raw(""));
lines.push(box_hint(hint));
texts.push(String::new());
texts.push(hint.to_string());
let width = box_width(area.width);
let rows: usize = texts
.iter()
.map(|t| wrapped_line_count(t, box_inner_width(width)))
.sum();
let rect = box_area(area, width, box_height(rows as u16, area.height));
f.render_widget(Clear, rect);
let para = Paragraph::new(lines)
.block(super::widgets::box_block(Color::Cyan, &p.title))
.wrap(Wrap { trim: false });
f.render_widget(para, rect);
}