use crate::creds::{self, Cred};
use crate::engines::{self, Conn, Engine};
use crate::history;
use crate::reach::{self, Reach};
use crate::settings::{self, ConnOrder, Settings};
use crate::tunnels;
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::widgets::{ListState, Padding};
use std::collections::HashMap;
use std::io::{self, Stdout};
use std::process::{Command, ExitStatus};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
type Term = Terminal<CrosstermBackend<Stdout>>;
mod confirm;
mod detail;
mod filter;
mod input;
mod picker;
mod probe;
mod prompt;
mod render;
mod widgets;
mod wizard;
use confirm::Confirm;
use detail::render_detail;
use picker::Picker;
use prompt::{Action, Kind, Prompt, render_prompt};
use render::ui;
use widgets::{centered, empty, titled, wrapped_line_count};
#[derive(Clone, Copy, PartialEq)]
pub(super) enum View {
Connections,
Passwords,
Tunnels,
Settings,
}
const VIEWS: [View; 4] = [
View::Connections,
View::Passwords,
View::Tunnels,
View::Settings,
];
const CONN_HINTS: &str = "↵ open · c new · e edit · d del · p password · t tunnel · y yank · Y url · r refresh · / find · ? help";
const PASS_HINTS: &str = "c new · d forget · r refresh · / find · ? help";
const TUNNELS_HINTS: &str = "d kill · r refresh · / find · ? help";
const SETTINGS_HINTS: &str = "↵ change · d back to default · r reload · ? help";
const STATUS_TTL: Duration = Duration::from_millis(1500);
pub(super) struct PendingRun {
pub(super) argv: Vec<String>,
pub(super) label: String,
pub(super) connect: Option<Conn>,
}
pub(super) struct App {
pub(super) view: View,
pub(super) conns: Vec<Conn>,
pub(super) creds: Vec<Cred>,
pub(super) tunnels: Vec<tunnels::Tunnel>,
pub(super) conn_state: ListState,
pub(super) cred_state: ListState,
pub(super) tunnel_state: ListState,
pub(super) prompt: Option<Prompt>,
pub(super) picker: Option<Picker>,
pub(super) confirm: Option<Confirm>,
pub(super) status: String,
pub(super) status_at: Option<Instant>,
pub(super) show_help: bool,
pub(super) should_quit: bool,
pub(super) query: String,
pub(super) searching: bool,
pub(super) history: history::History,
pub(super) reach: HashMap<String, Reach>,
pub(super) reach_gen: u64,
pub(super) reach_tx: Sender<reach::Msg>,
pub(super) reach_rx: Receiver<reach::Msg>,
pub(super) installed: [bool; 4],
pub(super) settings: Settings,
pub(super) settings_state: ListState,
}
impl App {
pub(super) fn empty() -> Self {
let (tx, rx) = channel();
App {
view: View::Connections,
conns: Vec::new(),
creds: Vec::new(),
tunnels: Vec::new(),
conn_state: ListState::default().with_selected(Some(0)),
cred_state: ListState::default().with_selected(Some(0)),
tunnel_state: ListState::default().with_selected(Some(0)),
prompt: None,
picker: None,
confirm: None,
status: String::new(),
status_at: None,
show_help: false,
should_quit: false,
query: String::new(),
searching: false,
history: history::History::default(),
reach: HashMap::new(),
reach_gen: 0,
reach_tx: tx,
reach_rx: rx,
installed: [true; 4],
settings: Settings::default(),
settings_state: ListState::default().with_selected(Some(0)),
}
}
pub(super) fn set_status(&mut self, msg: impl Into<String>) {
self.status = msg.into();
self.status_at = Some(Instant::now());
}
pub(super) fn live_status(&self) -> Option<&str> {
let at = self.status_at?;
(at.elapsed() < STATUS_TTL && !self.status.is_empty()).then_some(self.status.as_str())
}
pub(super) fn new() -> Self {
let mut app = Self::empty();
app.settings = settings::load();
app.history = history::load();
app.refresh_all();
app.start_probes();
app
}
pub(super) fn refresh_all(&mut self) {
self.refresh_conns();
self.refresh_creds();
self.refresh_tunnels();
}
pub(super) fn refresh_conns(&mut self) {
self.conns = engines::list();
self.sort_conns();
for e in engines::ENGINES {
self.installed[e.idx()] = engines::client_installed(e, &self.settings);
}
let n = self.conn_rows().len();
Self::clamp(&mut self.conn_state, n);
}
pub(super) fn sort_conns(&mut self) {
match self.settings.conn_order {
ConnOrder::Recent => {
let history = &self.history;
self.conns.sort_by_key(|c| history.rank(&c.key()));
}
ConnOrder::Alpha => self
.conns
.sort_by(|a, b| (a.engine.idx(), &a.name).cmp(&(b.engine.idx(), &b.name))),
}
}
pub(super) fn refresh_creds(&mut self) {
self.creds = creds::list();
let n = self.cred_rows().len();
Self::clamp(&mut self.cred_state, n);
}
pub(super) fn tunnel_carrying(&self, c: &Conn) -> Option<String> {
let port = c.port_or_default();
self.tunnels.iter().find_map(|t| {
let (open, target, onward) = t.ports()?;
let same_host = target.eq_ignore_ascii_case(&c.host)
|| crate::sshhosts::is_same_machine(&t.host, &c.host)
&& matches!(target, "127.0.0.1" | "localhost");
(t.kind == 'L' && same_host && onward == port).then(|| open.to_string())
})
}
pub(super) fn refresh_tunnels(&mut self) {
self.tunnels = tunnels::list();
let n = self.tunnel_rows().len();
Self::clamp(&mut self.tunnel_state, n);
}
pub(super) fn has_password(&self, c: &Conn) -> bool {
c.engine.stores_password() && self.creds.iter().any(|cred| cred.covers(c))
}
pub(super) fn start_probes(&mut self) {
self.reach_gen += 1;
if !self.settings.probe {
self.reach.clear();
return;
}
let targets: Vec<reach::Target> = self
.conns
.iter()
.filter(|c| c.engine.networked())
.map(|c| reach::Target {
key: c.key(),
host: if c.host.is_empty() {
"localhost".to_string()
} else {
c.host.clone()
},
port: c.port_or_default().parse().unwrap_or(0),
})
.filter(|t| t.port != 0)
.collect();
for t in &targets {
self.reach.insert(t.key.clone(), Reach::Probing);
}
reach::probe_all(
targets,
self.reach_gen,
self.reach_tx.clone(),
self.settings.probe_timeout,
);
}
pub(super) fn drain_probes(&mut self) -> bool {
let mut changed = false;
while let Ok(msg) = self.reach_rx.try_recv() {
if msg.generation != self.reach_gen {
continue; }
self.reach.insert(msg.key, msg.reach);
changed = true;
}
changed
}
pub(super) fn probing(&self) -> bool {
self.reach.values().any(|r| *r == Reach::Probing)
}
pub(super) fn conn_rows(&self) -> Vec<usize> {
self.conns
.iter()
.enumerate()
.filter(|(_, c)| {
let target = c.target();
filter::matches(&self.query, &[&c.name, &target, c.engine.label()])
})
.map(|(i, _)| i)
.collect()
}
pub(super) fn cred_rows(&self) -> Vec<usize> {
self.creds
.iter()
.enumerate()
.filter(|(_, c)| filter::matches(&self.query, &[&c.describe()]))
.map(|(i, _)| i)
.collect()
}
pub(super) fn tunnel_rows(&self) -> Vec<usize> {
self.tunnels
.iter()
.enumerate()
.filter(|(_, t)| filter::matches(&self.query, &[&t.describe()]))
.map(|(i, _)| i)
.collect()
}
pub(super) fn settings_rows(&self) -> Vec<settings::Row> {
self.settings
.rows()
.into_iter()
.filter(|r| filter::matches(&self.query, &[r.label, r.key, &r.value, r.help]))
.collect()
}
pub(super) fn selected_setting(&self) -> Option<settings::Row> {
let i = self.settings_state.selected()?;
self.settings_rows().into_iter().nth(i)
}
pub(super) fn row_count(&self) -> usize {
match self.view {
View::Connections => self.conn_rows().len(),
View::Passwords => self.cred_rows().len(),
View::Tunnels => self.tunnel_rows().len(),
View::Settings => self.settings_rows().len(),
}
}
pub(super) fn goto_view(&mut self, view: View) {
self.view = view;
if !self.query.is_empty() || self.searching {
self.query.clear();
self.searching = false;
self.requery();
}
}
pub(super) fn select_tunnel(&mut self, pid: u32) {
let at = self
.tunnel_rows()
.iter()
.position(|&r| self.tunnels[r].pid == pid);
if let Some(i) = at {
self.tunnel_state.select(Some(i));
}
}
pub(super) fn select_conn(&mut self, key: &str) {
let at = self
.conn_rows()
.iter()
.position(|&r| self.conns[r].key() == key);
if let Some(i) = at {
self.conn_state.select(Some(i));
}
}
pub(super) fn requery(&mut self) {
self.conn_state.select(Some(0));
self.cred_state.select(Some(0));
self.tunnel_state.select(Some(0));
self.settings_state.select(Some(0));
let n = self.conn_rows().len();
Self::clamp(&mut self.conn_state, n);
let n = self.cred_rows().len();
Self::clamp(&mut self.cred_state, n);
let n = self.tunnel_rows().len();
Self::clamp(&mut self.tunnel_state, n);
let n = self.settings_rows().len();
Self::clamp(&mut self.settings_state, n);
}
pub(super) fn clamp(state: &mut ListState, len: usize) {
if len == 0 {
state.select(None);
} else {
let sel = state.selected().unwrap_or(0).min(len - 1);
state.select(Some(sel));
}
}
pub(super) fn selected_conn(&self) -> Option<&Conn> {
let row = *self.conn_rows().get(self.conn_state.selected()?)?;
self.conns.get(row)
}
pub(super) fn selected_cred(&self) -> Option<&Cred> {
let row = *self.cred_rows().get(self.cred_state.selected()?)?;
self.creds.get(row)
}
pub(super) fn selected_tunnel(&self) -> Option<&tunnels::Tunnel> {
let row = *self.tunnel_rows().get(self.tunnel_state.selected()?)?;
self.tunnels.get(row)
}
pub(super) fn cycle_view(&mut self, delta: i32) {
let i = VIEWS.iter().position(|v| *v == self.view).unwrap_or(0) as i32;
let n = (i + delta).rem_euclid(VIEWS.len() as i32) as usize;
self.view = VIEWS[n];
if !self.query.is_empty() || self.searching {
self.query.clear();
self.searching = false;
self.requery();
}
}
pub(super) fn move_sel(&mut self, delta: i32) {
let len = self.row_count();
match self.view {
View::Connections => Self::move_state(&mut self.conn_state, len, delta),
View::Passwords => Self::move_state(&mut self.cred_state, len, delta),
View::Tunnels => Self::move_state(&mut self.tunnel_state, len, delta),
View::Settings => Self::move_state(&mut self.settings_state, len, delta),
}
}
pub(super) fn move_state(state: &mut ListState, len: usize, delta: i32) {
if len == 0 {
return;
}
let cur = state.selected().unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(len as i32) as usize;
state.select(Some(next));
}
}
pub fn run() -> Result<()> {
install_panic_hook();
let mut terminal = setup()?;
let mut app = App::new();
let res = event_loop(&mut terminal, &mut app);
teardown(&mut terminal)?;
res
}
fn event_loop(terminal: &mut Term, app: &mut App) -> Result<()> {
while !app.should_quit {
terminal.draw(|f| ui(f, app))?;
let timeout = if app.live_status().is_some() || app.probing() {
Duration::from_millis(200)
} else {
Duration::from_secs(3600)
};
if !event::poll(timeout)? {
app.drain_probes();
continue; }
app.drain_probes();
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if let Some(run) = app.on_key(key) {
let status = run_suspended(terminal, &run.argv)?;
if let Some(conn) = run.connect.clone() {
match status {
Some(s) if s.success() => {
history::record(&conn.key());
app.history = history::load();
}
Some(_) => app.offer_fix(&conn),
None => {}
}
}
let msg = match status {
Some(s) if s.success() => format!("{} ✓", run.label),
Some(s) => format!("{} failed (exit {})", run.label, s.code().unwrap_or(-1)),
None => format!("{}: could not run '{}'", run.label, run.argv[0]),
};
app.set_status(msg);
app.refresh_all();
}
}
Ok(())
}
fn run_suspended(terminal: &mut Term, argv: &[String]) -> Result<Option<ExitStatus>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
let status = Command::new(&argv[0]).args(&argv[1..]).status().ok();
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.hide_cursor()?;
terminal.clear()?;
Ok(status)
}
fn setup() -> Result<Term> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn teardown(terminal: &mut Term) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn install_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
hook(info);
}));
}