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) hint: String,
pub(crate) value: String,
pub(crate) kind: Kind,
pub(crate) choice: usize,
pub(crate) required: bool,
}
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(),
hint: String::new(),
value: String::new(),
kind: Kind::Text,
choice: 0,
required: false,
}
}
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 required(mut self) -> Self {
self.required = true;
self
}
pub(super) fn hint(mut self, hint: &str) -> Self {
self.hint = hint.into();
self
}
pub(super) fn placeholder(&self) -> String {
[self.default.as_str(), self.hint.as_str()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" ")
}
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", &get(|c| &c.name))
.required()
.hint("what you type after esql"),
Field::filled("Database file", &get(|c| &c.database)).required(),
];
}
let port = Field {
default: engine.default_port().to_string(),
..Field::filled("Port", &get(|c| &c.port))
};
let mut fields = vec![
Field::filled("Name", &get(|c| &c.name))
.required()
.hint("what you type after esql"),
Field::filled("Host", &get(|c| &c.host)).hint("IP or DNS name"),
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", &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",
if c.host.is_empty() {
"localhost"
} else {
&c.host
},
),
Field::filled("Port", &c.port_or_default()),
Field::filled("Database", "*"),
Field::filled("User", &c.user),
Field::secret("Password")
.required()
.hint("never shown, never in an argv"),
],
_ => vec![
Field::secret("Password")
.required()
.hint("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", &via)
.required()
.hint("an ssh host"),
Field::filled("Database host", &db_host).required(),
Field::filled("Database port", &c.port_or_default()).required(),
Field::new("Local port", "= database port").hint("where you'll reach it"),
],
}
}
}
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", &cred.host),
Field::filled("Port", &cred.port),
Field::filled("Database", &cred.database),
Field::filled("User", &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", from.map(|s| s.name.as_str()).unwrap_or(""))
.required()
.hint("typed after the connection, as :name"),
Field::filled("SQL", &from.map(|s| s.summary()).unwrap_or_default())
.required()
.hint("one line - o opens $EDITOR for a longer one"),
],
}
}
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 note(&self) -> &'static str {
match &self.action {
Action::SetPassword { .. } | Action::EditPassword { .. } if self.fields.len() > 1 => {
"* in a field matches any host, port, database or user"
}
Action::Forward { .. } => {
"ctrl-o picks the ssh host · the database host is what that machine sees"
}
_ => "",
}
}
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,
}
}
}
const LABEL_COL: usize = 14;
const LABEL_COL_MAX: usize = 22;
fn label_width(field: &Field) -> usize {
field.label.chars().count() + usize::from(field.required) + 1
}
fn value_column(fields: &[Field]) -> usize {
let widest = fields.iter().map(label_width).max().unwrap_or(0) + 2;
widest.clamp(LABEL_COL, LABEL_COL_MAX)
}
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();
let dim = Style::default().add_modifier(Modifier::DIM);
let col = value_column(&p.fields);
for (i, field) in p.fields.iter().enumerate() {
let active = i == p.idx;
let star = if field.required { "*" } else { "" };
let label_style = if active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::DIM)
};
let pad = " ".repeat(col.saturating_sub(label_width(field)).max(1));
let mut spans = vec![
Span::raw(if active { "▸ " } else { " " }),
Span::styled(field.label.clone(), label_style),
Span::styled(star, Style::default().fg(Color::Red)),
Span::styled(format!(":{pad}"), label_style),
];
let value = field.display();
let tail = if field.is_choice() {
let mut style = Style::default().fg(Color::Cyan);
if active {
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(value.clone(), style));
String::new()
} else if value.is_empty() {
let example = field.placeholder();
spans.push(Span::raw(if active { "█ " } else { "" }));
spans.push(Span::styled(example.clone(), dim));
example
} else {
spans.push(Span::raw(value.clone()));
spans.push(Span::raw(if active { "█" } else { "" }));
String::new()
};
texts.push(format!(
"{}{}{}:{pad}{}{}",
if active { "▸ " } else { " " },
field.label,
star,
value,
tail
));
lines.push(Line::from(spans));
}
if !p.note().is_empty() {
lines.push(Line::raw(""));
texts.push(String::new());
lines.push(Line::from(Span::styled(p.note(), dim)));
texts.push(p.note().to_string());
}
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 ", 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, dim)));
}
let mut hint = "↑↓ tab move · ←→ choose · enter next/submit · esc cancel".to_string();
if p.fields.iter().any(|f| f.required) {
hint.push_str(" · * required");
}
lines.push(Line::raw(""));
lines.push(box_hint(&hint));
texts.push(String::new());
texts.push(hint.clone());
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);
}