use crossterm::event::{KeyCode, KeyEvent};
use log::{debug, info};
use super::ctx::{Effectful, Effects, Nav, Notify};
use crate::app::{
App, ConflictState, FormState, HostState, Screen, StatusCenter, TopPage, TunnelState,
UiSelection,
};
use crate::history::ConnectionHistory;
pub(super) struct TunnelCtx<'a> {
tunnels: &'a mut TunnelState,
hosts: &'a mut HostState,
ui: &'a mut UiSelection,
status: &'a mut StatusCenter,
forms: &'a mut FormState,
conflict: &'a mut ConflictState,
history: &'a mut ConnectionHistory,
screen: &'a mut Screen,
demo_mode: bool,
top_page: TopPage,
bw_session: Option<&'a str>,
config_path: &'a std::path::Path,
pub(super) effects: Effects,
}
impl Nav for TunnelCtx<'_> {
fn screen_mut(&mut self) -> &mut Screen {
self.screen
}
}
impl Notify for TunnelCtx<'_> {
fn status_mut(&mut self) -> &mut StatusCenter {
self.status
}
}
impl Effectful for TunnelCtx<'_> {
fn effects_mut(&mut self) -> &mut Effects {
&mut self.effects
}
}
impl TunnelCtx<'_> {
fn refresh_tunnel_list(&mut self, alias: &str) {
self.tunnels.load_directives(self.hosts.ssh_config(), alias);
}
fn close_tunnel_form(&mut self, return_to: Screen) {
log::debug!(
"[purple] close_tunnel_form return_to={:?}",
std::mem::discriminant(&return_to)
);
self.conflict.clear_form_mtimes();
self.tunnels.set_form_baseline(None);
self.set_screen(return_to);
}
fn config_changed_since_form_open(&self) -> bool {
crate::app::config_changed(self.conflict, self.config_path)
}
fn fix_selection_after_list_change(&mut self) {
if self.tunnels.list().is_empty() {
self.ui.tunnel_list_state_mut().select(None);
} else if let Some(sel) = self.ui.tunnel_list_state().selected() {
if sel >= self.tunnels.list().len() {
self.ui
.tunnel_list_state_mut()
.select(Some(self.tunnels.list().len() - 1));
}
} else {
self.ui.tunnel_list_state_mut().select(Some(0));
}
}
pub(super) fn stop_active_tunnel(&mut self, alias: &str) -> bool {
let Some(mut tunnel) = self.tunnels.active_remove(alias) else {
return false;
};
if let Err(e) = tunnel.child.kill() {
debug!("[external] Failed to kill tunnel process for {alias}: {e}");
}
let _ = tunnel.child.wait();
drop(tunnel);
debug!("[purple] tunnel stopped: alias={alias}");
self.defer(|app| app.refresh_tunnel_bind_ports());
self.notify(crate::messages::tunnel_stopped(alias));
true
}
pub(super) fn spawn_active_tunnel(&mut self, alias: &str) -> bool {
let askpass = self
.hosts
.list()
.iter()
.find(|h| h.alias == alias)
.and_then(|h| h.askpass.clone());
let rules = self.hosts.ssh_config().find_tunnel_directives(alias);
match crate::tunnel::start_tunnel(
alias,
self.config_path,
askpass.as_deref(),
self.bw_session,
) {
Ok(child) => {
for rule in &rules {
info!(
"Tunnel started: type={} local={} remote={}:{} alias={alias}",
rule.tunnel_type.label(),
rule.bind_port,
rule.remote_host,
rule.remote_port
);
}
self.tunnels.ensure_lsof_poller();
let parser_tx = self.tunnels.parser_tx();
let active = crate::tunnel::ActiveTunnel::spawn(child, alias, parser_tx);
self.tunnels.active_insert(alias.to_string(), active);
self.defer(|app| app.refresh_tunnel_bind_ports());
self.history.record(alias);
self.record_key_use(alias);
self.apply_sort();
self.notify(crate::messages::tunnel_started(alias));
true
}
Err(e) => {
log::error!("[external] tunnel start failed: alias={alias}: {e}");
self.notify_error(crate::messages::tunnel_start_failed(&e));
false
}
}
}
pub(super) fn remove_forward_tx(&mut self, alias: &str, key: &str, value: &str) -> bool {
let config_backup = self.hosts.ssh_config().clone();
if !self
.hosts
.ssh_config_mut()
.remove_forward(alias, key, value)
{
self.notify_warning(crate::messages::TUNNEL_NOT_FOUND);
return false;
}
if let Err(e) = self.hosts.ssh_config().write() {
self.hosts.set_ssh_config(config_backup);
self.notify_error(crate::messages::failed_to_save(&e));
return false;
}
self.update_last_modified();
self.reload_hosts();
true
}
}
fn tunnel_form_return_screen(top_page: TopPage, alias: &str) -> Screen {
if matches!(top_page, TopPage::Tunnels) {
Screen::HostList
} else {
Screen::TunnelList {
alias: alias.to_string(),
}
}
}
pub(super) fn handle_tunnel_list_key(app: &mut App, key: KeyEvent) {
let effects = {
let mut ctx = ctx_from_app(app);
tunnel_list_key(&mut ctx, key);
ctx.effects
};
effects.apply(app);
}
pub(super) fn handle_tunnel_form_key(app: &mut App, key: KeyEvent) {
let effects = {
let mut ctx = ctx_from_app(app);
tunnel_form_key(&mut ctx, key);
ctx.effects
};
effects.apply(app);
}
pub(super) fn ctx_from_app(app: &mut App) -> TunnelCtx<'_> {
TunnelCtx {
tunnels: &mut app.tunnels,
hosts: &mut app.hosts_state,
ui: &mut app.ui,
status: &mut app.status_center,
forms: &mut app.forms,
conflict: &mut app.conflict,
history: &mut app.history,
screen: &mut app.screen,
demo_mode: app.demo_mode,
top_page: app.top_page,
bw_session: app.bw_session.as_deref(),
config_path: app.reload.config_path(),
effects: Effects::default(),
}
}
fn tunnel_list_key(ctx: &mut TunnelCtx, key: KeyEvent) {
let alias = match &*ctx.screen {
Screen::TunnelList { alias } => alias.clone(),
_ => return,
};
if ctx.tunnels.pending_delete().is_some() && key.code != KeyCode::Char('?') {
match super::route_confirm_key(key) {
super::ConfirmAction::Yes => {
let Some(sel) = ctx.tunnels.take_pending_delete() else {
return;
};
let Some((directive_key, value)) = ctx.tunnels.list().get(sel).map(|rule| {
(
rule.tunnel_type.directive_key().to_string(),
rule.to_directive_value(),
)
}) else {
return;
};
if ctx.remove_forward_tx(&alias, &directive_key, &value) {
ctx.refresh_tunnel_list(&alias);
if ctx.tunnels.list().is_empty() {
ctx.ui.tunnel_list_state_mut().select(None);
} else if sel >= ctx.tunnels.list().len() {
ctx.ui
.tunnel_list_state_mut()
.select(Some(ctx.tunnels.list().len() - 1));
}
ctx.notify(crate::messages::TUNNEL_REMOVED);
}
}
super::ConfirmAction::No => {
ctx.tunnels.cancel_delete();
}
super::ConfirmAction::Ignored => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
ctx.set_screen(Screen::HostList);
}
KeyCode::Char('j') | KeyCode::Down => {
crate::app::cycle_selection(
ctx.ui.tunnel_list_state_mut(),
ctx.tunnels.list().len(),
true,
);
}
KeyCode::Char('k') | KeyCode::Up => {
crate::app::cycle_selection(
ctx.ui.tunnel_list_state_mut(),
ctx.tunnels.list().len(),
false,
);
}
KeyCode::PageDown => {
crate::app::page_down(ctx.ui.tunnel_list_state_mut(), ctx.tunnels.list().len(), 10);
}
KeyCode::PageUp => {
crate::app::page_up(ctx.ui.tunnel_list_state_mut(), ctx.tunnels.list().len(), 10);
}
KeyCode::Char('a') => {
if host_is_read_only(ctx, &alias) {
ctx.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
return;
}
let alias = alias.clone();
ctx.effects
.defer(move |app| app.open_tunnel_add_form(alias));
}
KeyCode::Char('e') => {
if host_is_read_only(ctx, &alias) {
ctx.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
return;
}
if let Some(sel) = ctx.ui.tunnel_list_state().selected() {
if let Some(rule) = ctx.tunnels.list().get(sel).cloned() {
let alias = alias.clone();
ctx.effects
.defer(move |app| app.open_tunnel_edit_form(alias, &rule, sel));
}
}
}
KeyCode::Char('d') => {
if host_is_read_only(ctx, &alias) {
ctx.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
return;
}
if let Some(sel) = ctx.ui.tunnel_list_state().selected() {
if sel < ctx.tunnels.list().len() {
ctx.tunnels.request_delete(sel);
}
}
}
KeyCode::Enter => {
if ctx.tunnels.active_contains(&alias) {
ctx.stop_active_tunnel(&alias);
} else if !ctx.tunnels.list().is_empty() {
if ctx.demo_mode {
ctx.notify_warning(crate::messages::DEMO_TUNNELS_DISABLED);
return;
}
ctx.spawn_active_tunnel(&alias);
}
}
KeyCode::Char('?') => {
let old = (*ctx.screen).clone();
ctx.set_screen(Screen::Help {
return_screen: Box::new(old),
});
}
_ => {}
}
}
fn host_is_read_only(ctx: &TunnelCtx, alias: &str) -> bool {
ctx.hosts
.list()
.iter()
.find(|h| h.alias == alias)
.is_some_and(|h| h.source_file.is_some())
}
fn tunnel_form_key(ctx: &mut TunnelCtx, key: KeyEvent) {
let (alias, editing) = match &*ctx.screen {
Screen::TunnelForm { alias, editing } => (alias.clone(), *editing),
_ => return,
};
if ctx.forms.is_discard_pending() {
match super::route_confirm_key(key) {
super::ConfirmAction::Yes => {
ctx.forms.dismiss_discard_confirm();
let return_to = tunnel_form_return_screen(ctx.top_page, &alias);
ctx.close_tunnel_form(return_to);
}
super::ConfirmAction::No => {
ctx.forms.dismiss_discard_confirm();
}
super::ConfirmAction::Ignored => {}
}
return;
}
match key.code {
KeyCode::Esc => {
if ctx.tunnels.form_is_dirty() {
ctx.forms.request_discard_confirm();
} else {
let return_to = tunnel_form_return_screen(ctx.top_page, &alias);
ctx.close_tunnel_form(return_to);
}
}
KeyCode::Tab | KeyCode::Down => {
ctx.tunnels.form_mut().focus_next();
}
KeyCode::BackTab | KeyCode::Up => {
ctx.tunnels.form_mut().focus_prev();
}
KeyCode::Left if ctx.tunnels.form_mut().cursor_pos > 0 => {
ctx.tunnels.form_mut().cursor_pos -= 1;
}
KeyCode::Right => {
let len = ctx
.tunnels
.form()
.focused_value()
.map(|v| v.chars().count())
.unwrap_or(0);
if ctx.tunnels.form_mut().cursor_pos < len {
ctx.tunnels.form_mut().cursor_pos += 1;
}
}
KeyCode::Home => {
ctx.tunnels.form_mut().cursor_pos = 0;
}
KeyCode::End => {
ctx.tunnels.form_mut().sync_cursor_to_end();
}
KeyCode::Enter => {
submit_tunnel_form(ctx, &alias, editing);
}
KeyCode::Char(' ')
if ctx.tunnels.form_mut().focused_field == crate::app::TunnelFormField::Type =>
{
ctx.tunnels.form_mut().tunnel_type = ctx.tunnels.form_mut().tunnel_type.next();
}
KeyCode::Char(c) => {
ctx.tunnels.form_mut().insert_char(c);
}
KeyCode::Backspace => {
ctx.tunnels.form_mut().delete_char_before_cursor();
}
_ => {}
}
}
fn submit_tunnel_form(ctx: &mut TunnelCtx, alias: &str, editing: Option<usize>) {
if ctx.config_changed_since_form_open() {
ctx.notify_warning(crate::messages::CONFIG_CHANGED_EXTERNALLY);
return;
}
if let Err(msg) = ctx.tunnels.form_mut().validate() {
ctx.notify_error(msg);
return;
}
let (directive_key, directive_value) = ctx.tunnels.form_mut().to_directive();
let config_backup = ctx.hosts.ssh_config().clone();
if let Some(idx) = editing {
if let Some(old_rule) = ctx.tunnels.list().get(idx) {
let old_key = old_rule.tunnel_type.directive_key().to_string();
let old_value = old_rule.to_directive_value();
if !ctx
.hosts
.ssh_config_mut()
.remove_forward(alias, &old_key, &old_value)
{
ctx.hosts.set_ssh_config(config_backup);
ctx.notify_warning(crate::messages::TUNNEL_ORIGINAL_NOT_FOUND);
return;
}
} else {
ctx.notify_warning(crate::messages::TUNNEL_LIST_CHANGED);
return;
}
}
if ctx
.hosts
.ssh_config()
.has_forward(alias, directive_key, &directive_value)
{
ctx.hosts.set_ssh_config(config_backup);
ctx.notify_warning(crate::messages::TUNNEL_DUPLICATE);
return;
}
ctx.hosts
.ssh_config_mut()
.add_forward(alias, directive_key, &directive_value);
if let Err(e) = ctx.hosts.ssh_config().write() {
ctx.hosts.set_ssh_config(config_backup);
ctx.notify_error(crate::messages::failed_to_save(&e));
return;
}
ctx.hosts.clear_undo(); ctx.update_last_modified();
ctx.refresh_tunnel_list(alias);
ctx.reload_hosts();
ctx.fix_selection_after_list_change();
ctx.notify(crate::messages::TUNNEL_SAVED);
let return_to = tunnel_form_return_screen(ctx.top_page, alias);
ctx.close_tunnel_form(return_to);
}