use std::collections::HashSet;
use std::time::Duration;
use super::*;
use crate::ssh::sftp::{self, FileEntry, SftpCommand, SftpManager, SftpOpKind};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FmPanel {
#[default]
Local,
Remote,
}
#[derive(Debug, Clone)]
pub struct FmClipboard {
pub paths: Vec<String>,
pub source_panel: FmPanel,
}
#[derive(Debug, Default)]
pub struct FilePanelView {
pub cwd: String,
pub entries: Vec<FileEntry>,
pub cursor: usize,
pub scroll: std::cell::Cell<usize>,
pub marked: HashSet<String>,
}
impl FilePanelView {
pub fn cursor_entry(&self) -> Option<&FileEntry> {
self.entries.get(self.cursor)
}
pub fn marked_or_cursor_paths(&self) -> Vec<String> {
if !self.marked.is_empty() {
self.marked.iter().cloned().collect()
} else if let Some(e) = self.cursor_entry() {
if e.name != ".." {
vec![e.path.clone()]
} else {
vec![]
}
} else {
vec![]
}
}
pub fn select_next(&mut self) {
if !self.entries.is_empty() {
self.cursor = (self.cursor + 1).min(self.entries.len() - 1);
}
}
pub fn select_prev(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn clamp_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
let scroll = self.scroll.get();
if self.cursor < scroll {
self.scroll.set(self.cursor);
} else if self.cursor >= scroll + visible_rows {
self.scroll
.set(self.cursor.saturating_sub(visible_rows - 1));
}
}
}
#[derive(Debug)]
pub enum FileManagerPopup {
HostPicker { cursor: usize },
DeleteConfirm { paths: Vec<String> },
MkDir(FormField),
Rename {
original_name: String,
field: FormField,
},
TransferProgress {
transfer_id: TransferId,
filename: String,
done: u64,
total: u64,
},
}
#[derive(Debug, Default)]
pub struct FileManagerView {
pub active_panel: FmPanel,
pub local: FilePanelView,
pub remote: FilePanelView,
pub connected_host: Option<String>,
pub sftp_connecting: bool,
pub clipboard: Option<FmClipboard>,
pub popup: Option<FileManagerPopup>,
pub preview_content: Option<String>,
pub preview_path: Option<String>,
pub active_transfer: Option<TransferId>,
pub pending_ops: usize,
}
impl App {
pub(crate) fn active_fm_panel_mut(&mut self) -> &mut FilePanelView {
match self.view.file_manager.active_panel {
FmPanel::Local => &mut self.view.file_manager.local,
FmPanel::Remote => &mut self.view.file_manager.remote,
}
}
pub(crate) fn active_fm_panel_ref(&self) -> &FilePanelView {
match self.view.file_manager.active_panel {
FmPanel::Local => &self.view.file_manager.local,
FmPanel::Remote => &self.view.file_manager.remote,
}
}
pub(crate) async fn bootstrap_file_manager(&mut self) {
if self.view.file_manager.local.cwd.is_empty() {
let start = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string());
let tx = self.event_tx.clone();
let path = start.clone();
tokio::spawn(async move {
match sftp::list_local_dir(&path).await {
Ok(entries) => {
let _ = tx.send(AppEvent::LocalDirListed { path, entries }).await;
}
Err(e) => tracing::warn!("Local bootstrap failed: {e}"),
}
});
}
if self.sftp_manager.is_none() && self.view.file_manager.connected_host.is_none() {
self.view.file_manager.popup = Some(FileManagerPopup::HostPicker { cursor: 0 });
}
}
pub(crate) fn request_preview_for_active(&mut self) {
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
let (path, already_shown) = {
let panel = self.active_fm_panel_ref();
let Some(entry) = panel.cursor_entry() else {
return;
};
if entry.is_dir {
return;
}
let path = entry.path.clone();
let shown = self.view.file_manager.preview_path.as_deref() == Some(&path);
(path, shown)
};
if already_shown {
return;
}
if is_remote {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::ReadPreview(path));
}
} else {
let tx = self.event_tx.clone();
tokio::spawn(async move {
if let Ok(content) = sftp::preview_local_file(&path).await {
let _ = tx.send(AppEvent::FilePreviewReady { path, content }).await;
}
});
}
}
pub(crate) async fn refresh_active_panels(&mut self) {
let local_path = self.view.file_manager.local.cwd.clone();
if !local_path.is_empty() {
let tx = self.event_tx.clone();
tokio::spawn(async move {
match sftp::list_local_dir(&local_path).await {
Ok(entries) => {
let _ = tx
.send(AppEvent::LocalDirListed {
path: local_path,
entries,
})
.await;
}
Err(e) => tracing::warn!("Local refresh failed: {e}"),
}
});
}
let remote_path = self.view.file_manager.remote.cwd.clone();
if !remote_path.is_empty() {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::ListDir(remote_path));
}
}
}
pub(crate) async fn fm_connect_host(&mut self, idx: usize) {
let host = {
let state = self.state.read().await;
state.hosts.get(idx).cloned()
};
let Some(host) = host else {
self.view.status_message = Some("Host not found.".to_string());
return;
};
if let Some(old) = self.sftp_manager.take() {
old.disconnect();
}
self.view.file_manager.connected_host = None;
self.view.file_manager.remote = FilePanelView::default();
self.view.status_message = Some(format!("Connecting to '{}'… (30s timeout)", host.name));
self.view.file_manager.sftp_connecting = true;
let tx = self.event_tx.clone();
let host_clone = host.clone();
tokio::spawn(async move {
let connect_future = SftpManager::connect(&host_clone, tx.clone());
let timeout_future = tokio::time::sleep(Duration::from_secs(30));
tokio::select! {
result = connect_future => {
match result {
Ok(mgr) => {
let _ = tx
.send(AppEvent::SftpManagerReady {
host_name: host_clone.name.clone(),
manager: Box::new(mgr),
})
.await;
}
Err(e) => {
let _ = tx
.send(AppEvent::SftpDisconnected {
host_name: host_clone.name.clone(),
reason: e.to_string(),
})
.await;
}
}
}
_ = timeout_future => {
let _ = tx
.send(AppEvent::SftpDisconnected {
host_name: host_clone.name.clone(),
reason: "connection timed out (30s)".to_string(),
})
.await;
}
}
});
}
pub(crate) async fn fm_enter_dir(&mut self) {
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
let entry = self.active_fm_panel_ref().cursor_entry().cloned();
let Some(entry) = entry else { return };
if !entry.is_dir {
return;
}
if is_remote {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::ListDir(entry.path.clone()));
}
} else {
let path = entry.path.clone();
let tx = self.event_tx.clone();
tokio::spawn(async move {
match sftp::list_local_dir(&path).await {
Ok(entries) => {
let _ = tx.send(AppEvent::LocalDirListed { path, entries }).await;
}
Err(e) => {
let _ = tx
.send(AppEvent::Error("local".to_string(), e.to_string()))
.await;
}
}
});
}
}
pub(crate) async fn fm_parent_dir(&mut self) {
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
let cwd = self.active_fm_panel_ref().cwd.clone();
let parent = std::path::Path::new(&cwd).parent().map(|p| {
let s = p.to_string_lossy().into_owned();
if s.is_empty() {
"/".to_string()
} else {
s
}
});
let Some(parent) = parent else { return };
if is_remote {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::ListDir(parent));
}
} else {
let tx = self.event_tx.clone();
tokio::spawn(async move {
match sftp::list_local_dir(&parent).await {
Ok(entries) => {
let _ = tx
.send(AppEvent::LocalDirListed {
path: parent,
entries,
})
.await;
}
Err(e) => tracing::warn!("Parent dir failed: {e}"),
}
});
}
}
pub(crate) async fn fm_paste(&mut self) {
let Some(clipboard) = self.view.file_manager.clipboard.clone() else {
self.view.status_message = Some("Nothing in clipboard.".to_string());
return;
};
let dst_panel = self.view.file_manager.active_panel.clone();
if clipboard.source_panel == dst_panel {
self.view.status_message = Some("Cannot paste to the same panel.".to_string());
return;
}
if clipboard.paths.is_empty() {
self.view.status_message = Some("Clipboard is empty.".to_string());
return;
}
let dst_cwd = match &dst_panel {
FmPanel::Local => self.view.file_manager.local.cwd.clone(),
FmPanel::Remote => self.view.file_manager.remote.cwd.clone(),
};
let count = clipboard.paths.len();
let first_tid = self.next_transfer_id;
self.next_transfer_id += count as u64;
self.view.file_manager.pending_ops = count;
let first_name = filename_of(&clipboard.paths[0]);
let popup_name = if count > 1 {
format!("{first_name} (+{} more)", count - 1)
} else {
first_name
};
self.view.file_manager.active_transfer = Some(first_tid);
self.view.file_manager.popup = Some(FileManagerPopup::TransferProgress {
transfer_id: first_tid,
filename: popup_name,
done: 0,
total: 0,
});
for (i, src_path) in clipboard.paths.iter().enumerate() {
let tid = first_tid + i as u64;
let fname = filename_of(src_path);
let dst = format!("{}/{}", dst_cwd.trim_end_matches('/'), fname);
match (&clipboard.source_panel, &dst_panel) {
(FmPanel::Local, FmPanel::Remote) => {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::Upload {
local: src_path.clone(),
remote: dst,
transfer_id: tid,
});
}
}
(FmPanel::Remote, FmPanel::Local) => {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::Download {
remote: src_path.clone(),
local: dst,
transfer_id: tid,
});
}
}
_ => unreachable!("same-panel case handled above"),
}
}
if count > 1 {
self.view.status_message = Some(format!("Queued {count} files for transfer…"));
}
}
pub(crate) async fn fm_delete(&mut self) {
let popup = self.view.file_manager.popup.take();
let Some(FileManagerPopup::DeleteConfirm { paths }) = popup else {
return;
};
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
if is_remote {
for path in paths {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::Delete(path));
}
}
} else {
let tx = self.event_tx.clone();
tokio::spawn(async move {
let mut last_err: Option<String> = None;
for path in paths {
let result = tokio::fs::remove_file(&path).await.or_else(|_| {
std::fs::remove_dir(&path)
.map_err(|e| std::io::Error::new(e.kind(), e.to_string()))
});
if let Err(e) = result {
last_err = Some(e.to_string());
}
}
let result = last_err.map_or(Ok(()), Err);
let _ = tx
.send(AppEvent::SftpOpDone {
kind: SftpOpKind::Delete,
result: result.map_err(|e: String| e),
})
.await;
});
}
}
pub(crate) async fn fm_mkdir(&mut self, name: String) {
self.view.file_manager.popup = None;
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
if is_remote {
let cwd = self.view.file_manager.remote.cwd.clone();
let new_path = format!("{}/{}", cwd.trim_end_matches('/'), name);
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::MkDir(new_path));
}
} else {
let cwd = self.view.file_manager.local.cwd.clone();
let new_path = format!("{}/{}", cwd.trim_end_matches('/'), name);
let tx = self.event_tx.clone();
tokio::spawn(async move {
let result = tokio::fs::create_dir(&new_path)
.await
.map_err(|e| e.to_string());
let _ = tx
.send(AppEvent::SftpOpDone {
kind: SftpOpKind::MkDir,
result,
})
.await;
});
}
}
pub(crate) async fn fm_rename(&mut self, new_name: String) {
let popup = self.view.file_manager.popup.take();
let is_remote = self.view.file_manager.active_panel == FmPanel::Remote;
let cwd = match is_remote {
true => self.view.file_manager.remote.cwd.clone(),
false => self.view.file_manager.local.cwd.clone(),
};
let old_name = match &popup {
Some(FileManagerPopup::Rename { original_name, .. }) => original_name.clone(),
_ => return,
};
let old_path = format!("{}/{}", cwd.trim_end_matches('/'), old_name);
let new_path = format!("{}/{}", cwd.trim_end_matches('/'), new_name);
if is_remote {
if let Some(mgr) = &self.sftp_manager {
mgr.send(SftpCommand::Rename {
from: old_path,
to: new_path,
});
}
} else {
let tx = self.event_tx.clone();
tokio::spawn(async move {
let result = tokio::fs::rename(&old_path, &new_path)
.await
.map_err(|e| e.to_string());
let _ = tx
.send(AppEvent::SftpOpDone {
kind: SftpOpKind::Rename,
result,
})
.await;
});
}
}
}
fn filename_of(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, path: &str) -> FileEntry {
FileEntry {
name: name.to_string(),
path: path.to_string(),
size: 0,
is_dir: false,
is_symlink: false,
permissions: 0,
modified: None,
}
}
fn panel(entries: Vec<FileEntry>) -> FilePanelView {
FilePanelView {
entries,
..FilePanelView::default()
}
}
#[test]
fn marked_or_cursor_uses_marked_set() {
let mut p = panel(vec![entry("a", "/a"), entry("b", "/b")]);
p.marked.insert("/b".to_string());
assert_eq!(p.marked_or_cursor_paths(), vec!["/b".to_string()]);
}
#[test]
fn marked_or_cursor_uses_cursor_when_unmarked() {
let p = panel(vec![entry("f", "/f")]);
assert_eq!(p.marked_or_cursor_paths(), vec!["/f".to_string()]);
}
#[test]
fn marked_or_cursor_excludes_dotdot_under_cursor() {
let p = panel(vec![entry("..", "/parent")]);
assert!(p.marked_or_cursor_paths().is_empty());
}
#[test]
fn marked_or_cursor_empty_entries_returns_empty() {
let p = panel(vec![]);
assert!(p.marked_or_cursor_paths().is_empty());
}
#[test]
fn marked_or_cursor_marked_dotdot_still_returned() {
let mut p = panel(vec![entry("..", "/parent")]);
p.marked.insert("/parent".to_string());
assert_eq!(p.marked_or_cursor_paths(), vec!["/parent".to_string()]);
}
#[test]
fn cursor_entry_in_bounds() {
let mut p = panel(vec![entry("a", "/a"), entry("b", "/b")]);
p.cursor = 1;
assert_eq!(p.cursor_entry().map(|e| e.name.as_str()), Some("b"));
}
#[test]
fn cursor_entry_out_of_bounds_returns_none() {
let mut p = panel(vec![entry("a", "/a")]);
p.cursor = 5;
assert!(p.cursor_entry().is_none());
}
#[test]
fn fm_select_next_clamps_at_last() {
let mut p = panel(vec![entry("a", "/a"), entry("b", "/b")]);
p.select_next();
p.select_next();
p.select_next();
assert_eq!(p.cursor, 1);
}
#[test]
fn fm_select_next_noop_when_empty() {
let mut p = panel(vec![]);
p.select_next();
assert_eq!(p.cursor, 0);
}
#[test]
fn fm_select_prev_saturates_at_zero() {
let mut p = panel(vec![entry("a", "/a")]);
p.select_prev();
assert_eq!(p.cursor, 0);
}
#[test]
fn clamp_scroll_pulls_view_up_to_cursor() {
let mut p = panel(vec![]);
p.cursor = 2;
p.scroll.set(5);
p.clamp_scroll(10);
assert_eq!(p.scroll.get(), 2);
}
#[test]
fn clamp_scroll_pushes_view_down_to_cursor() {
let mut p = panel(vec![]);
p.cursor = 20;
p.scroll.set(0);
p.clamp_scroll(10);
assert_eq!(p.scroll.get(), 11);
}
#[test]
fn clamp_scroll_zero_rows_is_noop() {
let mut p = panel(vec![]);
p.cursor = 20;
p.scroll.set(5);
p.clamp_scroll(0);
assert_eq!(p.scroll.get(), 5);
}
}