mirror of
https://github.com/veeso/termscp.git
synced 2026-04-03 08:41:33 -07:00
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.
518 lines
19 KiB
Rust
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,
|
|
¶ms.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, ¶ms.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()
|
|
}
|
|
}
|
|
}
|
|
}
|