Files
termscp/src/activity_manager.rs
Christian Visintin a252caa66b refactor: FileTransferActivity pane-agnostic dispatch (#386)
Comprehensive design for incremental refactoring of the 13k-line
FileTransferActivity god-struct using a unified Pane abstraction.
Detailed step-by-step plan covering 6 phases: split monoliths,
error handling, Pane struct, action dedup, session split, view reorg.
Extract 26 popup components from the monolithic 1,868-line popups.rs
into 20 individual files under popups/. Each file contains one or two
related components with their own imports. The popups.rs module file
now contains only module declarations and re-exports.
Replace 8 panic!() calls with error!() logging and early returns/fallthrough.
These panics documented invariants (e.g. "this tab can't do X") but would crash
the app if somehow triggered. Error logging is safer and more resilient.
Replace raw FileExplorer fields in Browser with Pane structs that bundle
the explorer and connected state. Move host_bridge_connected and
remote_connected from FileTransferActivity into the panes. Add navigation
API (fs_pane, opposite_pane, is_find_tab) for future unification tasks.
Rename private get_selected_file to get_selected_file_by_id and add three
new unified methods (get_selected_entries, get_selected_file, is_selected_one)
that dispatch based on self.browser.tab(). Old per-tab methods are kept for
now until their callers are migrated in subsequent tasks.
Collapse _local_/_remote_ action method pairs (mkdir, delete, symlink,
chmod, rename, copy) into unified methods that branch internally on
is_local_tab(). This halves the number of action methods and simplifies
the update.rs dispatch logic. Also unifies ShowFileInfoPopup and
ShowChmodPopup dispatching to use get_selected_entries().
Move `host_bridge` and `client` filesystem fields from FileTransferActivity
into the Pane struct, enabling tab-agnostic dispatch via `fs_pane()`/
`fs_pane_mut()`. This eliminates most `is_local_tab()` branching across
15+ action files.
Key changes:
- Add `fs: Box<dyn HostBridge>` to Pane, remove from FileTransferActivity
- Replace per-side method pairs with unified pane-dispatched methods
- Unify navigation (changedir, reload, scan, file_exists, has_file_changed)
- Replace 147-line popup if/else chain with data-driven priority table
- Replace assert!/panic!/unreachable! with proper error handling
- Fix typo "filetransfer_activiy" across ~29 files
- Add unit tests for Pane

Net result: -473 lines, single code path for most file operations.
2026-02-27 21:58:31 +01:00

518 lines
19 KiB
Rust

//! ## ActivityManager
//!
//! `activity_manager` is the module which provides run methods and handling for activities
use std::env;
use std::path::PathBuf;
use std::time::Duration;
use remotefs_ssh::SshKeyStorage as SshKeyStorageTrait;
use crate::cli::{Remote, RemoteArgs};
use crate::filetransfer::{
FileTransferParams, FileTransferProtocol, HostBridgeParams, ProtocolParams,
};
use crate::host::HostError;
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::config_client::ConfigClient;
use crate::system::environment;
use crate::system::sshkey_storage::SshKeyStorage;
use crate::system::theme_provider::ThemeProvider;
use crate::ui::activities::auth::AuthActivity;
use crate::ui::activities::filetransfer::FileTransferActivity;
use crate::ui::activities::setup::SetupActivity;
use crate::ui::activities::{Activity, ExitReason};
use crate::ui::context::Context;
use crate::utils::{fmt, tty};
/// NextActivity identifies the next identity to run once the current has ended
pub enum NextActivity {
Authentication,
FileTransfer,
SetupActivity,
}
pub enum Host {
HostBridge,
Remote,
}
pub enum HostParams {
HostBridge(HostBridgeParams),
Remote(FileTransferParams),
}
/// The activity manager takes care of running activities and handling them until the application has ended
pub struct ActivityManager {
context: Option<Context>,
ticks: Duration,
}
impl ActivityManager {
/// Initializes a new Activity Manager
pub fn new(ticks: Duration, keyring: bool) -> Result<ActivityManager, HostError> {
// Prepare Context
// Initialize configuration client
let (config_client, error_config): (ConfigClient, Option<String>) =
match Self::init_config_client() {
Ok(cli) => (cli, None),
Err(err) => {
error!("Failed to initialize config client: {}", err);
(ConfigClient::degraded(), Some(err))
}
};
let (bookmarks_client, error_bookmark) = match Self::init_bookmarks_client(keyring) {
Ok(cli) => (cli, None),
Err(err) => (None, Some(err)),
};
let error = error_config.or(error_bookmark);
let theme_provider: ThemeProvider = Self::init_theme_provider();
let ctx: Context = Context::new(bookmarks_client, config_client, theme_provider, error);
Ok(ActivityManager {
context: Some(ctx),
ticks,
})
}
/// Configure remote args
pub fn configure_remote_args(&mut self, remote_args: RemoteArgs) -> Result<(), String> {
// Set for host bridge
match remote_args.host_bridge {
Remote::Bookmark(params) => self.resolve_bookmark_name(
Host::HostBridge,
&params.name,
params.password.as_deref(),
),
Remote::Host(host_params) => self.set_host_params(
HostParams::HostBridge(HostBridgeParams::Remote(
host_params.file_transfer_params.protocol,
host_params.file_transfer_params.params,
)),
host_params.password.as_deref(),
),
Remote::None => {
// local dir is remote_args.local_dir if set, otherwise current dir
let local_dir = remote_args
.local_dir
.unwrap_or_else(|| env::current_dir().unwrap());
debug!("host bridge is None, setting local dir to {:?}", local_dir,);
self.set_host_params(
HostParams::HostBridge(HostBridgeParams::Localhost(local_dir)),
None,
)
}
}?;
// set remote
match remote_args.remote {
Remote::Bookmark(params) => {
self.resolve_bookmark_name(Host::Remote, &params.name, params.password.as_deref())
}
Remote::Host(host_params) => self.set_host_params(
HostParams::Remote(host_params.file_transfer_params),
host_params.password.as_deref(),
),
Remote::None => Ok(()),
}
}
/// Set file transfer params
pub fn set_host_params(
&mut self,
host: HostParams,
password: Option<&str>,
) -> Result<(), String> {
let (remote_local_path, remote_remote_path) = match &host {
HostParams::Remote(params) => (params.local_path.clone(), params.remote_path.clone()),
_ => (None, None),
};
let mut remote_params = match &host {
HostParams::HostBridge(HostBridgeParams::Remote(protocol, protocol_params)) => {
Some((*protocol, protocol_params.clone()))
}
HostParams::HostBridge(HostBridgeParams::Localhost(_)) => None,
HostParams::Remote(ft_params) => Some((ft_params.protocol, ft_params.params.clone())),
};
// Put params into the context
if let Some((protocol, params)) = remote_params.as_mut() {
self.resolve_password_for_protocol_params(*protocol, params, password)?;
}
match host {
HostParams::HostBridge(HostBridgeParams::Localhost(path)) => {
self.context
.as_mut()
.unwrap()
.set_host_bridge_params(HostBridgeParams::Localhost(path));
}
HostParams::HostBridge(HostBridgeParams::Remote(_, _)) => {
let (protocol, params) = remote_params.unwrap();
self.context
.as_mut()
.unwrap()
.set_host_bridge_params(HostBridgeParams::Remote(protocol, params));
}
HostParams::Remote(_) => {
let (protocol, params) = remote_params.unwrap();
let params = FileTransferParams {
local_path: remote_local_path,
remote_path: remote_remote_path,
protocol,
params,
};
self.context.as_mut().unwrap().set_remote_params(params);
}
}
Ok(())
}
fn resolve_password_for_protocol_params(
&mut self,
protocol: FileTransferProtocol,
params: &mut ProtocolParams,
password: Option<&str>,
) -> Result<(), String> {
// Set password if provided
if params.password_missing() {
if let Some(password) = password {
params.set_default_secret(password.to_string());
} else if matches!(
protocol,
FileTransferProtocol::Scp | FileTransferProtocol::Sftp,
) && params.generic_params().is_some()
{
// * if protocol is SCP or SFTP check whether a SSH key is registered for this remote, in case not ask password
let storage = SshKeyStorage::from(self.context.as_ref().unwrap().config());
let generic_params = params.generic_params().unwrap();
let username = generic_params
.username
.clone()
.map(Ok)
.unwrap_or_else(whoami::username)
.map_err(|err| format!("Could not get current username: {err}"))?;
if storage
.resolve(&generic_params.address, &username)
.is_none()
{
debug!(
"storage could not find any suitable key for {}... prompting for password",
generic_params.address
);
self.prompt_password(params)?;
} else {
debug!(
"a key is already set for {}; password is not required",
generic_params.address
);
}
} else {
self.prompt_password(params)?;
}
}
Ok(())
}
/// Prompt user for password to set into params.
fn prompt_password(&mut self, params: &mut ProtocolParams) -> Result<(), String> {
let ctx = self.context.as_mut().unwrap();
let prompt = format!("Password for {}: ", params.host_name());
match tty::read_secret_from_tty(ctx.terminal(), prompt) {
Err(err) => Err(format!("Could not read password: {err}")),
Ok(Some(secret)) => {
debug!(
"Read password from tty: {}",
fmt::shadow_password(secret.as_str())
);
params.set_default_secret(secret);
Ok(())
}
Ok(None) => Ok(()),
}
}
/// Resolve provided bookmark name and set it as file transfer params.
/// Returns error if bookmark is not found
pub fn resolve_bookmark_name(
&mut self,
host: Host,
bookmark_name: &str,
password: Option<&str>,
) -> Result<(), String> {
if let Some(bookmarks_client) = self.context.as_mut().unwrap().bookmarks_client_mut() {
let params = match bookmarks_client.get_bookmark(bookmark_name) {
None => {
return Err(format!(
r#"Could not resolve bookmark name: "{bookmark_name}" no such bookmark"#
));
}
Some(params) => params,
};
let params = match host {
Host::Remote => HostParams::Remote(params),
Host::HostBridge => {
HostParams::HostBridge(HostBridgeParams::Remote(params.protocol, params.params))
}
};
self.set_host_params(params, password)
} else {
Err(String::from(
"Could not resolve bookmark name: bookmarks client not initialized",
))
}
}
///
/// Loop for activity manager. You need to provide the activity to start with
/// Returns the exitcode
pub fn run(&mut self, launch_activity: NextActivity) {
let mut current_activity: Option<NextActivity> = Some(launch_activity);
loop {
current_activity = match current_activity {
Some(activity) => match activity {
NextActivity::Authentication => self.run_authentication(),
NextActivity::FileTransfer => self.run_filetransfer(),
NextActivity::SetupActivity => self.run_setup(),
},
None => break, // Exit
}
}
// Drop context
drop(self.context.take());
}
// -- Activity Loops
/// Loop for Authentication activity.
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_authentication(&mut self) -> Option<NextActivity> {
info!("Starting AuthActivity...");
// Prepare activity
let mut activity: AuthActivity = AuthActivity::new(self.ticks);
// Prepare result
let result: Option<NextActivity>;
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => {
error!("Failed to start AuthActivity: context is None");
return None;
}
};
// Create activity
activity.on_create(ctx);
loop {
// Draw activity
activity.on_draw();
// Check if has to be terminated
if let Some(exit_reason) = activity.will_umount() {
match exit_reason {
ExitReason::Quit => {
info!("AuthActivity terminated due to 'Quit'");
result = None;
break;
}
ExitReason::EnterSetup => {
// User requested activity
info!("AuthActivity terminated due to 'EnterSetup'");
result = Some(NextActivity::SetupActivity);
break;
}
ExitReason::Connect => {
// User submitted, set next activity
info!("AuthActivity terminated due to 'Connect'");
result = Some(NextActivity::FileTransfer);
break;
}
_ => { /* Nothing to do */ }
}
}
}
// Destroy activity
self.context = activity.on_destroy();
info!("AuthActivity destroyed");
result
}
/// Loop for FileTransfer activity.
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_filetransfer(&mut self) -> Option<NextActivity> {
info!("Starting FileTransferActivity");
// Get context
let mut ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => {
error!("Failed to start FileTransferActivity: context is None");
return None;
}
};
let host_bridge_params = match ctx.host_bridge_params() {
Some(params) => params.clone(),
None => {
error!("Failed to start FileTransferActivity: host bridge params is None");
return None;
}
};
// If ft params is None, return None
let remote_params: &FileTransferParams = match ctx.remote_params() {
Some(ft_params) => ft_params,
None => {
error!("Failed to start FileTransferActivity: file transfer params is None");
return None;
}
};
// try to setup activity
let mut activity =
match FileTransferActivity::new(host_bridge_params, remote_params, self.ticks) {
Ok(activity) => activity,
Err(err) => {
error!("Failed to start FileTransferActivity: {}", err);
ctx.set_error(err);
self.context = Some(ctx);
// Return to authentication
return Some(NextActivity::Authentication);
}
};
// Prepare result
let result: Option<NextActivity>;
// Create activity
activity.on_create(ctx);
loop {
// Draw activity
activity.on_draw();
// Check if has to be terminated
if let Some(exit_reason) = activity.will_umount() {
match exit_reason {
ExitReason::Quit => {
info!("FileTransferActivity terminated due to 'Quit'");
result = None;
break;
}
ExitReason::Disconnect => {
// User disconnected, set next activity to authentication
info!("FileTransferActivity terminated due to 'Authentication'");
result = Some(NextActivity::Authentication);
break;
}
_ => { /* Nothing to do */ }
}
}
}
// Destroy activity
self.context = activity.on_destroy();
result
}
/// `SetupActivity` run loop.
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_setup(&mut self) -> Option<NextActivity> {
// Prepare activity
let mut activity: SetupActivity = SetupActivity::new(self.ticks);
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => {
error!("Failed to start SetupActivity: context is None");
return None;
}
};
// Create activity
activity.on_create(ctx);
loop {
// Draw activity
activity.on_draw();
// Check if activity has terminated
if let Some(ExitReason::Quit) = activity.will_umount() {
info!("SetupActivity terminated due to 'Quit'");
break;
}
}
// Destroy activity
self.context = activity.on_destroy();
// This activity always returns to AuthActivity
Some(NextActivity::Authentication)
}
// -- misc
fn init_bookmarks_client(keyring: bool) -> Result<Option<BookmarksClient>, String> {
crate::support::bookmarks_client(keyring)
}
/// Initialize configuration client
fn init_config_client() -> Result<ConfigClient, String> {
// Get config dir
match environment::init_config_dir() {
Ok(config_dir) => {
match config_dir {
Some(config_dir) => {
// Get config client paths
let (config_path, ssh_dir): (PathBuf, PathBuf) =
environment::get_config_paths(config_dir.as_path());
match ConfigClient::new(config_path.as_path(), ssh_dir.as_path()) {
Ok(cli) => Ok(cli),
Err(err) => Err(format!("Could not read configuration: {err}")),
}
}
None => Err(String::from(
"Your system doesn't provide a configuration directory",
)),
}
}
Err(err) => Err(format!(
"Could not initialize configuration directory: {err}"
)),
}
}
fn init_theme_provider() -> ThemeProvider {
match environment::init_config_dir() {
Ok(config_dir) => {
match config_dir {
Some(config_dir) => {
// Get config client paths
let theme_path: PathBuf = environment::get_theme_path(config_dir.as_path());
match ThemeProvider::new(theme_path.as_path()) {
Ok(provider) => provider,
Err(err) => {
error!(
"Could not initialize theme provider with file '{}': {}; using theme provider in degraded mode",
theme_path.display(),
err
);
ThemeProvider::degraded()
}
}
}
None => {
error!(
"This system doesn't provide a configuration directory; using theme provider in degraded mode"
);
ThemeProvider::degraded()
}
}
}
Err(err) => {
error!(
"Could not initialize configuration directory: {}; using theme provider in degraded mode",
err
);
ThemeProvider::degraded()
}
}
}
}